Routing CRUD
This commit is contained in:
parent
c7302092bb
commit
bea1e67b19
43 changed files with 1333 additions and 377 deletions
|
|
@ -1,27 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\Article;
|
|
||||||
use App\Services\Article\ArticleFetcher;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
|
|
||||||
class FetchArticleCommand extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'article:fetch {url}';
|
|
||||||
|
|
||||||
protected $description = 'Fetch article from url';
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$article = Article::createQuietly([
|
|
||||||
'url' => $this->argument('url'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$res = ArticleFetcher::fetchArticleData($article);
|
|
||||||
|
|
||||||
dump($res);
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Services\Article\ArticleFetcher;
|
use App\Jobs\ArticleDiscoveryJob;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class FetchNewArticlesCommand extends Command
|
class FetchNewArticlesCommand extends Command
|
||||||
|
|
@ -13,9 +13,7 @@ class FetchNewArticlesCommand extends Command
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
logger('Fetch new articles command');
|
ArticleDiscoveryJob::dispatch();
|
||||||
|
|
||||||
ArticleFetcher::getNewArticles();
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,7 @@
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Enums\PlatformEnum;
|
|
||||||
use App\Jobs\SyncChannelPostsJob;
|
use App\Jobs\SyncChannelPostsJob;
|
||||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class SyncChannelPostsCommand extends Command
|
class SyncChannelPostsCommand extends Command
|
||||||
|
|
@ -22,39 +20,15 @@ public function handle(): int
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->error("Unsupported platform: {$platform}");
|
$this->error("Unsupported platform: {$platform}");
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncLemmy(): int
|
private function syncLemmy(): int
|
||||||
{
|
{
|
||||||
$communityName = config('lemmy.community');
|
SyncChannelPostsJob::dispatchForAllActiveChannels();
|
||||||
|
$this->info('Successfully dispatched sync jobs for all active Lemmy channels');
|
||||||
|
|
||||||
if (!$communityName) {
|
return self::SUCCESS;
|
||||||
$this->error('Missing Lemmy community configuration (lemmy.community)');
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->info("Getting community ID for: {$communityName}");
|
|
||||||
|
|
||||||
$api = new LemmyApiService(config('lemmy.instance'));
|
|
||||||
$communityId = $api->getCommunityId($communityName);
|
|
||||||
|
|
||||||
$this->info("Running sync job for Lemmy community: {$communityName} (ID: {$communityId})");
|
|
||||||
|
|
||||||
SyncChannelPostsJob::dispatchSync(
|
|
||||||
PlatformEnum::LEMMY,
|
|
||||||
(string) $communityId,
|
|
||||||
$communityName
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->info('Channel posts synced successfully');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->error("Failed to sync channel posts: {$e->getMessage()}");
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,11 @@
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
class ArticleFetched
|
class NewArticleFetched
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
public function __construct(public Article $article)
|
public function __construct(public Article $article)
|
||||||
{
|
{
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
app/Exceptions/ChannelException.php
Normal file
9
app/Exceptions/ChannelException.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class ChannelException extends Exception
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,14 @@ class PublishException extends Exception
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Article $article,
|
private readonly Article $article,
|
||||||
private readonly PlatformEnum $platform,
|
private readonly PlatformEnum|null $platform,
|
||||||
?Throwable $previous = null
|
?Throwable $previous = null
|
||||||
) {
|
) {
|
||||||
$message = "Failed to publish article #{$article->id} to {$platform->value}";
|
$message = "Failed to publish article #$article->id";
|
||||||
|
|
||||||
|
if ($this->platform) {
|
||||||
|
$message .= " to $platform->value";
|
||||||
|
}
|
||||||
|
|
||||||
if ($previous) {
|
if ($previous) {
|
||||||
$message .= ": {$previous->getMessage()}";
|
$message .= ": {$previous->getMessage()}";
|
||||||
|
|
@ -28,7 +32,7 @@ public function getArticle(): Article
|
||||||
return $this->article;
|
return $this->article;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPlatform(): PlatformEnum
|
public function getPlatform(): ?PlatformEnum
|
||||||
{
|
{
|
||||||
return $this->platform;
|
return $this->platform;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
app/Exceptions/RoutingException.php
Normal file
9
app/Exceptions/RoutingException.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class RoutingException extends Exception
|
||||||
|
{
|
||||||
|
}
|
||||||
22
app/Exceptions/RoutingMismatchException.php
Normal file
22
app/Exceptions/RoutingMismatchException.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
|
||||||
|
class RoutingMismatchException extends RoutingException
|
||||||
|
{
|
||||||
|
public function __construct(Feed $feed, PlatformChannel $channel)
|
||||||
|
{
|
||||||
|
$message = sprintf(
|
||||||
|
"Language mismatch: Feed '%s' is in '%s' but channel '%s' is set to '%s'. Feed and channel languages must match for proper content routing.",
|
||||||
|
$feed->name,
|
||||||
|
$feed->language,
|
||||||
|
$channel->name,
|
||||||
|
$channel->language
|
||||||
|
);
|
||||||
|
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
165
app/Http/Controllers/RoutingController.php
Normal file
165
app/Http/Controllers/RoutingController.php
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
use App\Services\RoutingValidationService;
|
||||||
|
use App\Exceptions\RoutingMismatchException;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
class RoutingController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$feeds = Feed::with(['channels.platformInstance'])
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$channels = PlatformChannel::with(['platformInstance', 'feeds'])
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('pages.routing.index', compact('feeds', 'channels'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): View
|
||||||
|
{
|
||||||
|
$feeds = Feed::where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$channels = PlatformChannel::with('platformInstance')
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('pages.routing.create', compact('feeds', 'channels'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'feed_id' => 'required|exists:feeds,id',
|
||||||
|
'channel_ids' => 'required|array|min:1',
|
||||||
|
'channel_ids.*' => 'exists:platform_channels,id',
|
||||||
|
'priority' => 'integer|min:0|max:100',
|
||||||
|
'filters' => 'nullable|string'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$feed = Feed::findOrFail($validated['feed_id']);
|
||||||
|
$channels = PlatformChannel::findMany($validated['channel_ids']);
|
||||||
|
$priority = $validated['priority'] ?? 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(RoutingValidationService::class)->validateLanguageCompatibility($feed, $channels);
|
||||||
|
} catch (RoutingMismatchException $e) {
|
||||||
|
return redirect()->back()
|
||||||
|
->withInput()
|
||||||
|
->withErrors(['language' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = $this->parseJsonFilters($validated['filters'] ?? null);
|
||||||
|
|
||||||
|
// Attach channels to feed
|
||||||
|
$syncData = [];
|
||||||
|
foreach ($validated['channel_ids'] as $channelId) {
|
||||||
|
$syncData[$channelId] = [
|
||||||
|
'is_active' => true,
|
||||||
|
'priority' => $priority,
|
||||||
|
'filters' => $filters,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$feed->channels()->syncWithoutDetaching($syncData);
|
||||||
|
|
||||||
|
return redirect()->route('routing.index')
|
||||||
|
->with('success', 'Feed routing created successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Feed $feed, PlatformChannel $channel): View
|
||||||
|
{
|
||||||
|
$routing = $feed->channels()
|
||||||
|
->wherePivot('platform_channel_id', $channel->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $routing) {
|
||||||
|
abort(404, 'Routing not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('pages.routing.edit', compact('feed', 'channel', 'routing'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Feed $feed, PlatformChannel $channel): RedirectResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'priority' => 'integer|min:0|max:100',
|
||||||
|
'filters' => 'nullable|string'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$filters = $this->parseJsonFilters($validated['filters'] ?? null);
|
||||||
|
|
||||||
|
$feed->channels()->updateExistingPivot($channel->id, [
|
||||||
|
'is_active' => $validated['is_active'] ?? true,
|
||||||
|
'priority' => $validated['priority'] ?? 0,
|
||||||
|
'filters' => $filters,
|
||||||
|
'updated_at' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('routing.index')
|
||||||
|
->with('success', 'Routing updated successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Feed $feed, PlatformChannel $channel): RedirectResponse
|
||||||
|
{
|
||||||
|
$feed->channels()->detach($channel->id);
|
||||||
|
|
||||||
|
return redirect()->route('routing.index')
|
||||||
|
->with('success', 'Routing deleted successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggle(Request $request, Feed $feed, PlatformChannel $channel): RedirectResponse
|
||||||
|
{
|
||||||
|
$routing = $feed->channels()
|
||||||
|
->wherePivot('platform_channel_id', $channel->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $routing) {
|
||||||
|
abort(404, 'Routing not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$newStatus = ! $routing->pivot->is_active;
|
||||||
|
|
||||||
|
$feed->channels()->updateExistingPivot($channel->id, [
|
||||||
|
'is_active' => $newStatus,
|
||||||
|
'updated_at' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = $newStatus ? 'activated' : 'deactivated';
|
||||||
|
|
||||||
|
return redirect()->route('routing.index')
|
||||||
|
->with('success', "Routing {$status} successfully!");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseJsonFilters(?string $json): ?array
|
||||||
|
{
|
||||||
|
if (empty($json)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode($json, true);
|
||||||
|
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE) {
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Jobs/ArticleDiscoveryForFeedJob.php
Normal file
61
app/Jobs/ArticleDiscoveryForFeedJob.php
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Feed;
|
||||||
|
use App\Services\Article\ArticleFetcher;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class ArticleDiscoveryForFeedJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
private const FEED_DISCOVERY_DELAY_MINUTES = 5;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly Feed $feed
|
||||||
|
) {
|
||||||
|
$this->onQueue('feed-discovery');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
LogSaver::info('Starting feed article fetch', null, [
|
||||||
|
'feed_id' => $this->feed->id,
|
||||||
|
'feed_name' => $this->feed->name,
|
||||||
|
'feed_url' => $this->feed->url
|
||||||
|
]);
|
||||||
|
|
||||||
|
$articles = ArticleFetcher::getArticlesFromFeed($this->feed);
|
||||||
|
|
||||||
|
LogSaver::info('Feed article fetch completed', null, [
|
||||||
|
'feed_id' => $this->feed->id,
|
||||||
|
'feed_name' => $this->feed->name,
|
||||||
|
'articles_count' => $articles->count()
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->feed->update(['last_fetched_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function dispatchForAllActiveFeeds(): void
|
||||||
|
{
|
||||||
|
Feed::where('is_active', true)
|
||||||
|
->get()
|
||||||
|
->each(function (Feed $feed, $index) {
|
||||||
|
// Space jobs apart to avoid overwhelming feeds
|
||||||
|
$delayMinutes = $index * self::FEED_DISCOVERY_DELAY_MINUTES;
|
||||||
|
|
||||||
|
self::dispatch($feed)
|
||||||
|
->delay(now()->addMinutes($delayMinutes))
|
||||||
|
->onQueue('feed-discovery');
|
||||||
|
|
||||||
|
LogSaver::info('Dispatched feed discovery job', null, [
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'feed_name' => $feed->name,
|
||||||
|
'delay_minutes' => $delayMinutes
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Jobs/ArticleDiscoveryJob.php
Normal file
26
app/Jobs/ArticleDiscoveryJob.php
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
class ArticleDiscoveryJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->onQueue('feed-discovery');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
LogSaver::info('Starting article discovery for all active feeds');
|
||||||
|
|
||||||
|
ArticleDiscoveryForFeedJob::dispatchForAllActiveFeeds();
|
||||||
|
|
||||||
|
LogSaver::info('Article discovery jobs dispatched for all active feeds');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
|
|
||||||
use App\Exceptions\PublishException;
|
use App\Exceptions\PublishException;
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Modules\Lemmy\Services\LemmyPublisher;
|
|
||||||
use App\Services\Article\ArticleFetcher;
|
use App\Services\Article\ArticleFetcher;
|
||||||
|
use App\Services\Publishing\ArticlePublishingService;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
@ -22,37 +22,14 @@ public function __construct(
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
if ($this->article->articlePublication !== null) {
|
|
||||||
logger()->info('Article already published, skipping', [
|
|
||||||
'article_id' => $this->article->id
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$extractedData = ArticleFetcher::fetchArticleData($this->article);
|
$extractedData = ArticleFetcher::fetchArticleData($this->article);
|
||||||
|
|
||||||
logger()->info('Publishing article to Lemmy', [
|
/** @var ArticlePublishingService $publishingService */
|
||||||
'article_id' => $this->article->id,
|
$publishingService = resolve(ArticlePublishingService::class);
|
||||||
'url' => $this->article->url
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$publications = LemmyPublisher::fromActiveAccount()->publish($this->article, $extractedData);
|
$publishingService->publishToRoutedChannels($this->article, $extractedData);
|
||||||
|
} catch (PublishException|RuntimeException $e) {
|
||||||
logger()->info('Article published successfully', [
|
|
||||||
'article_id' => $this->article->id,
|
|
||||||
'publications_count' => $publications->count(),
|
|
||||||
'communities' => $publications->pluck('community_id')->toArray()
|
|
||||||
]);
|
|
||||||
|
|
||||||
} catch (PublishException $e) {
|
|
||||||
$this->fail($e);
|
|
||||||
} catch (RuntimeException $e) {
|
|
||||||
logger()->warning('No active Lemmy accounts configured', [
|
|
||||||
'article_id' => $this->article->id,
|
|
||||||
'error' => $e->getMessage()
|
|
||||||
]);
|
|
||||||
$this->fail($e);
|
$this->fail($e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Jobs;
|
|
||||||
|
|
||||||
use App\Services\Article\ArticleFetcher;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
|
||||||
|
|
||||||
class RefreshArticlesJob implements ShouldQueue
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->onQueue('lemmy-posts');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(): void
|
|
||||||
{
|
|
||||||
ArticleFetcher::getNewArticles();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,72 +4,75 @@
|
||||||
|
|
||||||
use App\Enums\PlatformEnum;
|
use App\Enums\PlatformEnum;
|
||||||
use App\Exceptions\PlatformAuthException;
|
use App\Exceptions\PlatformAuthException;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
use App\Modules\Lemmy\Services\LemmyApiService;
|
use App\Modules\Lemmy\Services\LemmyApiService;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
class SyncChannelPostsJob implements ShouldQueue
|
class SyncChannelPostsJob implements ShouldQueue, ShouldBeUnique
|
||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PlatformEnum $platform,
|
private readonly PlatformChannel $channel
|
||||||
private readonly string $channelId,
|
|
||||||
private readonly string $channelName
|
|
||||||
) {
|
) {
|
||||||
$this->onQueue('lemmy-posts');
|
$this->onQueue('lemmy-posts');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function dispatchForLemmy(): void
|
public static function dispatchForAllActiveChannels(): void
|
||||||
{
|
{
|
||||||
$communityId = config('lemmy.community_id');
|
PlatformChannel::with(['platformInstance', 'platformAccounts'])
|
||||||
$communityName = config('lemmy.community');
|
->whereHas('platformInstance', fn ($query) => $query->where('platform', PlatformEnum::LEMMY))
|
||||||
|
->whereHas('platformAccounts', fn ($query) => $query->where('is_active', true))
|
||||||
if ($communityName) {
|
->where('is_active', true)
|
||||||
// Use a placeholder ID if community_id is not set - we'll resolve it in the job
|
->get()
|
||||||
$communityId = $communityId ?: 'resolve_from_name';
|
->each(function (PlatformChannel $channel) {
|
||||||
self::dispatch(PlatformEnum::LEMMY, (string) $communityId, $communityName);
|
self::dispatch($channel);
|
||||||
} else {
|
LogSaver::info('Dispatched sync job for channel', $channel);
|
||||||
logger()->warning('Cannot dispatch Lemmy sync job: missing community configuration');
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
echo "Starting channel posts sync job...\n";
|
LogSaver::info('Starting channel posts sync job', $this->channel);
|
||||||
|
|
||||||
if ($this->platform === PlatformEnum::LEMMY) {
|
if ($this->channel->platformInstance->platform === PlatformEnum::LEMMY) {
|
||||||
$this->syncLemmyChannelPosts();
|
$this->syncLemmyChannelPosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
echo "Channel posts sync job completed!\n";
|
LogSaver::info('Channel posts sync job completed', $this->channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws PlatformAuthException
|
||||||
|
*/
|
||||||
private function syncLemmyChannelPosts(): void
|
private function syncLemmyChannelPosts(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$api = new LemmyApiService(config('lemmy.instance'));
|
$account = $this->channel->platformAccounts()->where('is_active', true)->first();
|
||||||
$token = $this->getAuthToken($api);
|
|
||||||
|
|
||||||
// Resolve community ID if it's a placeholder
|
if (!$account) {
|
||||||
$communityId = $this->channelId === 'resolve_from_name'
|
throw new PlatformAuthException(PlatformEnum::LEMMY, 'No active account found for channel');
|
||||||
? $api->getCommunityId($this->channelName)
|
}
|
||||||
: (int) $this->channelId;
|
|
||||||
|
|
||||||
$api->syncChannelPosts($token, $communityId, $this->channelName);
|
$api = new LemmyApiService($this->channel->platformInstance->url);
|
||||||
|
$token = $this->getAuthToken($api, $account);
|
||||||
|
|
||||||
logger()->info('Channel posts synced successfully', [
|
// Get community ID from channel_id or resolve from name
|
||||||
'platform' => $this->platform->value,
|
$communityId = $this->channel->channel_id
|
||||||
'channel_id' => $this->channelId,
|
? (int) $this->channel->channel_id
|
||||||
'channel_name' => $this->channelName
|
: $api->getCommunityId($this->channel->name);
|
||||||
]);
|
|
||||||
|
$api->syncChannelPosts($token, $communityId, $this->channel->name);
|
||||||
|
|
||||||
|
LogSaver::info('Channel posts synced successfully', $this->channel);
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logger()->error('Failed to sync channel posts', [
|
LogSaver::error('Failed to sync channel posts', $this->channel, [
|
||||||
'platform' => $this->platform->value,
|
|
||||||
'channel_id' => $this->channelId,
|
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -77,28 +80,29 @@ private function syncLemmyChannelPosts(): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getAuthToken(LemmyApiService $api): string
|
/**
|
||||||
|
* @throws PlatformAuthException
|
||||||
|
*/
|
||||||
|
private function getAuthToken(LemmyApiService $api, \App\Models\PlatformAccount $account): string
|
||||||
{
|
{
|
||||||
$cachedToken = Cache::get('lemmy_jwt_token');
|
$cacheKey = "lemmy_jwt_token_{$account->id}";
|
||||||
|
$cachedToken = Cache::get($cacheKey);
|
||||||
|
|
||||||
if ($cachedToken) {
|
if ($cachedToken) {
|
||||||
return $cachedToken;
|
return $cachedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
$username = config('lemmy.username');
|
if (!$account->username || !$account->password) {
|
||||||
$password = config('lemmy.password');
|
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials for account');
|
||||||
|
|
||||||
if (!$username || !$password) {
|
|
||||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Missing credentials');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$token = $api->login($username, $password);
|
$token = $api->login($account->username, $account->password);
|
||||||
|
|
||||||
if (!$token) {
|
if (!$token) {
|
||||||
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed');
|
throw new PlatformAuthException(PlatformEnum::LEMMY, 'Login failed for account');
|
||||||
}
|
}
|
||||||
|
|
||||||
Cache::put('lemmy_jwt_token', $token, 3600);
|
Cache::put($cacheKey, $token, 3600);
|
||||||
|
|
||||||
return $token;
|
return $token;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,19 +14,18 @@ class PublishArticle implements ShouldQueue
|
||||||
public int $backoff = 300;
|
public int $backoff = 300;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{}
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(ArticleReadyToPublish $event): void
|
public function handle(ArticleReadyToPublish $event): void
|
||||||
{
|
{
|
||||||
$article = $event->article;
|
$article = $event->article;
|
||||||
|
|
||||||
// Check if already published to avoid duplicate jobs
|
|
||||||
if ($article->articlePublication()->exists()) {
|
if ($article->articlePublication()->exists()) {
|
||||||
logger()->info('Article already published, skipping job dispatch', [
|
logger()->info('Article already published, skipping job dispatch', [
|
||||||
'article_id' => $article->id,
|
'article_id' => $article->id,
|
||||||
'url' => $article->url
|
'url' => $article->url
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,16 @@
|
||||||
|
|
||||||
namespace App\Listeners;
|
namespace App\Listeners;
|
||||||
|
|
||||||
use App\Events\ArticleFetched;
|
use App\Events\NewArticleFetched;
|
||||||
use App\Events\ArticleReadyToPublish;
|
use App\Events\ArticleReadyToPublish;
|
||||||
use App\Services\Article\ValidationService;
|
use App\Services\Article\ValidationService;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
|
||||||
class ValidateArticle implements ShouldQueue
|
class ValidateArticleListener implements ShouldQueue
|
||||||
{
|
{
|
||||||
public string|null $queue = 'default';
|
public string $queue = 'default';
|
||||||
|
|
||||||
public function __construct()
|
public function handle(NewArticleFetched $event): void
|
||||||
{}
|
|
||||||
|
|
||||||
public function handle(ArticleFetched $event): void
|
|
||||||
{
|
{
|
||||||
$article = $event->article;
|
$article = $event->article;
|
||||||
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Events\ArticleFetched;
|
use App\Events\NewArticleFetched;
|
||||||
use Database\Factories\ArticleFactory;
|
use Database\Factories\ArticleFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
|
@ -15,6 +15,8 @@
|
||||||
* @method static where(string $string, string $url)
|
* @method static where(string $string, string $url)
|
||||||
* @method static create(string[] $array)
|
* @method static create(string[] $array)
|
||||||
* @property integer $id
|
* @property integer $id
|
||||||
|
* @property int $feed_id
|
||||||
|
* @property Feed $feed
|
||||||
* @property string $url
|
* @property string $url
|
||||||
* @property bool|null $is_valid
|
* @property bool|null $is_valid
|
||||||
* @property Carbon|null $validated_at
|
* @property Carbon|null $validated_at
|
||||||
|
|
@ -28,6 +30,7 @@ class Article extends Model
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'feed_id',
|
||||||
'url',
|
'url',
|
||||||
'title',
|
'title',
|
||||||
'description',
|
'description',
|
||||||
|
|
@ -67,15 +70,15 @@ public function articlePublication(): HasOne
|
||||||
return $this->hasOne(ArticlePublication::class);
|
return $this->hasOne(ArticlePublication::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function articlePublications(): HasMany
|
public function feed(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->hasMany(ArticlePublication::class);
|
return $this->belongsTo(Feed::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
static::created(function ($article) {
|
static::created(function ($article) {
|
||||||
event(new ArticleFetched($article));
|
event(new NewArticleFetched($article));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -10,7 +13,8 @@
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string $url
|
* @property string $url
|
||||||
* @property string $type
|
* @property string $type
|
||||||
* @property string $language
|
* @property int $language_id
|
||||||
|
* @property Language $language
|
||||||
* @property string $description
|
* @property string $description
|
||||||
* @property array $settings
|
* @property array $settings
|
||||||
* @property bool $is_active
|
* @property bool $is_active
|
||||||
|
|
@ -19,14 +23,19 @@
|
||||||
* @property Carbon $updated_at
|
* @property Carbon $updated_at
|
||||||
* @method static create(array $validated)
|
* @method static create(array $validated)
|
||||||
* @method static orderBy(string $string, string $string1)
|
* @method static orderBy(string $string, string $string1)
|
||||||
|
* @method static where(string $string, true $true)
|
||||||
|
* @method static findOrFail(mixed $feed_id)
|
||||||
*/
|
*/
|
||||||
class Feed extends Model
|
class Feed extends Model
|
||||||
{
|
{
|
||||||
|
private const RECENT_FETCH_THRESHOLD_HOURS = 2;
|
||||||
|
private const DAILY_FETCH_THRESHOLD_HOURS = 24;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'url',
|
'url',
|
||||||
'type',
|
'type',
|
||||||
'language',
|
'language_id',
|
||||||
'description',
|
'description',
|
||||||
'settings',
|
'settings',
|
||||||
'is_active',
|
'is_active',
|
||||||
|
|
@ -60,12 +69,36 @@ public function getStatusAttribute(): string
|
||||||
|
|
||||||
$hoursAgo = $this->last_fetched_at->diffInHours(now());
|
$hoursAgo = $this->last_fetched_at->diffInHours(now());
|
||||||
|
|
||||||
if ($hoursAgo < 2) {
|
if ($hoursAgo < self::RECENT_FETCH_THRESHOLD_HOURS) {
|
||||||
return 'Recently fetched';
|
return 'Recently fetched';
|
||||||
} elseif ($hoursAgo < 24) {
|
} elseif ($hoursAgo < self::DAILY_FETCH_THRESHOLD_HOURS) {
|
||||||
return "Fetched {$hoursAgo}h ago";
|
return "Fetched {$hoursAgo}h ago";
|
||||||
} else {
|
} else {
|
||||||
return "Fetched " . $this->last_fetched_at->diffForHumans();
|
return "Fetched " . $this->last_fetched_at->diffForHumans();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function channels(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(PlatformChannel::class, 'feed_platform_channels')
|
||||||
|
->withPivot(['is_active', 'priority', 'filters'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activeChannels(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->channels()
|
||||||
|
->wherePivot('is_active', true)
|
||||||
|
->orderByPivot('priority', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function articles(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Article::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function language(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Language::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
38
app/Models/Language.php
Normal file
38
app/Models/Language.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Language extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'short_code',
|
||||||
|
'name',
|
||||||
|
'native_name',
|
||||||
|
'is_active'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean'
|
||||||
|
];
|
||||||
|
|
||||||
|
public function platformInstances(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(PlatformInstance::class)
|
||||||
|
->withPivot(['platform_language_id', 'is_default'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function platformChannels(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PlatformChannel::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function feeds(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Feed::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,16 @@
|
||||||
|
|
||||||
use App\LogLevelEnum;
|
use App\LogLevelEnum;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static create(array $array)
|
||||||
|
* @property LogLevelEnum $level
|
||||||
|
* @property string $message
|
||||||
|
* @property array $context
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property Carbon $updated_at
|
||||||
|
*/
|
||||||
class Log extends Model
|
class Log extends Model
|
||||||
{
|
{
|
||||||
protected $table = 'logs';
|
protected $table = 'logs';
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,15 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @method static create(array $validated)
|
* @method static create(array $validated)
|
||||||
|
* @method static findMany(mixed $channel_ids)
|
||||||
|
* @property integer $id
|
||||||
|
* @property integer $platform_instance_id
|
||||||
* @property PlatformInstance $platformInstance
|
* @property PlatformInstance $platformInstance
|
||||||
|
* @property integer $channel_id
|
||||||
|
* @property string $name
|
||||||
|
* @property int $language_id
|
||||||
|
* @property Language $language
|
||||||
|
* @property boolean $is_active
|
||||||
*/
|
*/
|
||||||
class PlatformChannel extends Model
|
class PlatformChannel extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -20,6 +28,7 @@ class PlatformChannel extends Model
|
||||||
'display_name',
|
'display_name',
|
||||||
'channel_id',
|
'channel_id',
|
||||||
'description',
|
'description',
|
||||||
|
'language_id',
|
||||||
'is_active'
|
'is_active'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -45,4 +54,23 @@ public function getFullNameAttribute(): string
|
||||||
$prefix = $this->platformInstance->platform === 'lemmy' ? '/c/' : '/';
|
$prefix = $this->platformInstance->platform === 'lemmy' ? '/c/' : '/';
|
||||||
return $this->platformInstance->url . $prefix . $this->name;
|
return $this->platformInstance->url . $prefix . $this->name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function feeds(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Feed::class, 'feed_platform_channels')
|
||||||
|
->withPivot(['is_active', 'priority', 'filters'])
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activeFeeds(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->feeds()
|
||||||
|
->wherePivot('is_active', true)
|
||||||
|
->orderByPivot('priority', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function language(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Language::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Enums\PlatformEnum;
|
use App\Enums\PlatformEnum;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -30,9 +31,16 @@ class PlatformInstance extends Model
|
||||||
'is_active' => 'boolean'
|
'is_active' => 'boolean'
|
||||||
];
|
];
|
||||||
|
|
||||||
public function communities(): HasMany
|
public function channels(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Community::class);
|
return $this->hasMany(PlatformChannel::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function languages(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Language::class)
|
||||||
|
->withPivot(['platform_language_id', 'is_default'])
|
||||||
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function findByUrl(PlatformEnum $platform, string $url): ?self
|
public static function findByUrl(PlatformEnum $platform, string $url): ?self
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,12 @@
|
||||||
|
|
||||||
namespace App\Modules\Lemmy\Services;
|
namespace App\Modules\Lemmy\Services;
|
||||||
|
|
||||||
use App\Enums\PlatformEnum;
|
|
||||||
use App\Exceptions\PlatformAuthException;
|
use App\Exceptions\PlatformAuthException;
|
||||||
use App\Exceptions\PublishException;
|
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
use App\Models\ArticlePublication;
|
|
||||||
use App\Models\PlatformAccount;
|
use App\Models\PlatformAccount;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
use App\Services\Auth\LemmyAuthService;
|
use App\Services\Auth\LemmyAuthService;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class LemmyPublisher
|
class LemmyPublisher
|
||||||
{
|
{
|
||||||
|
|
@ -25,130 +20,26 @@ public function __construct(PlatformAccount $account)
|
||||||
$this->account = $account;
|
$this->account = $account;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fromActiveAccount(): self
|
|
||||||
{
|
|
||||||
$accounts = PlatformAccount::getActive(PlatformEnum::LEMMY);
|
|
||||||
|
|
||||||
if ($accounts->isEmpty()) {
|
|
||||||
throw new RuntimeException('No active Lemmy accounts configured'); // TODO Also make this into a PublishException
|
|
||||||
}
|
|
||||||
|
|
||||||
return new self($accounts->first());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws PublishException
|
|
||||||
*/
|
|
||||||
public function publish(Article $article, array $extractedData): Collection
|
|
||||||
{
|
|
||||||
$publications = collect();
|
|
||||||
$activeChannels = $this->account->activeChannels;
|
|
||||||
|
|
||||||
if ($activeChannels->isEmpty()) {
|
|
||||||
throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('No active channels configured for account: ' . $this->account->username));
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeChannels->each(function ($channel) use ($article, $extractedData, $publications) {
|
|
||||||
try {
|
|
||||||
$publication = $this->publishToChannel($article, $extractedData, $channel);
|
|
||||||
$publications->push($publication);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
logger()->warning('Failed to publish to channel', [
|
|
||||||
'article_id' => $article->id,
|
|
||||||
'channel' => $channel->name,
|
|
||||||
'error' => $e->getMessage()
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($publications->isEmpty()) {
|
|
||||||
throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('Failed to publish to any channel'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $publications;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws PlatformAuthException
|
* @throws PlatformAuthException
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
private function publishToChannel(Article $article, array $extractedData, $channel): ArticlePublication
|
public function publishToChannel(Article $article, array $extractedData, PlatformChannel $channel): array
|
||||||
{
|
{
|
||||||
$token = LemmyAuthService::getToken($this->account);
|
$token = LemmyAuthService::getToken($this->account);
|
||||||
$languageId = $this->getLanguageIdForSource($article->url);
|
|
||||||
|
|
||||||
$postData = $this->api->createPost(
|
// Use the language ID from extracted data (should be set during validation)
|
||||||
|
$languageId = $extractedData['language_id'] ?? null;
|
||||||
|
|
||||||
|
return $this->api->createPost(
|
||||||
$token,
|
$token,
|
||||||
$extractedData['title'] ?? 'Untitled',
|
$extractedData['title'] ?? 'Untitled',
|
||||||
$extractedData['description'] ?? '',
|
$extractedData['description'] ?? '',
|
||||||
(int) $channel->channel_id,
|
$channel->channel_id,
|
||||||
$article->url,
|
$article->url,
|
||||||
$extractedData['thumbnail'] ?? null,
|
$extractedData['thumbnail'] ?? null,
|
||||||
$languageId
|
$languageId
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->createPublicationRecord($article, $postData, (int) $channel->channel_id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createPublicationRecord(Article $article, array $postData, int $channelId): ArticlePublication
|
|
||||||
{
|
|
||||||
return ArticlePublication::create([
|
|
||||||
'article_id' => $article->id,
|
|
||||||
'post_id' => $postData['post_view']['post']['id'],
|
|
||||||
'community_id' => $channelId,
|
|
||||||
'published_by' => $this->account->username,
|
|
||||||
'published_at' => now(),
|
|
||||||
'platform' => 'lemmy',
|
|
||||||
'publication_data' => $postData,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getLanguageIdForSource(string $url): ?int
|
|
||||||
{
|
|
||||||
// TODO this will be obsolete when sources can be created from the UI, so we can remove these hard-coded sources
|
|
||||||
|
|
||||||
// VRT articles are in Dutch
|
|
||||||
if (str_contains($url, 'vrt.be')) {
|
|
||||||
return $this->getLanguageId('nl'); // Dutch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Belga articles are in English (based on URL structure)
|
|
||||||
if (str_contains($url, 'belganewsagency.eu')) {
|
|
||||||
return $this->getLanguageId('en'); // English
|
|
||||||
}
|
|
||||||
|
|
||||||
return null; // Default to no language specified
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getLanguageId(string $languageCode): ?int
|
|
||||||
{
|
|
||||||
$cacheKey = "lemmy_language_id_$languageCode";
|
|
||||||
$cachedId = Cache::get($cacheKey);
|
|
||||||
|
|
||||||
if ($cachedId !== null) {
|
|
||||||
return $cachedId;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$languages = $this->api->getLanguages();
|
|
||||||
|
|
||||||
foreach ($languages as $language) {
|
|
||||||
if (isset($language['code']) && $language['code'] === $languageCode) {
|
|
||||||
$languageId = $language['id'];
|
|
||||||
Cache::put($cacheKey, $languageId, 3600);
|
|
||||||
return $languageId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache null result to avoid repeated API calls
|
|
||||||
Cache::put($cacheKey, null, 3600);
|
|
||||||
return null;
|
|
||||||
} catch (Exception $e) {
|
|
||||||
logger()->warning('Failed to get language ID', [
|
|
||||||
'language_code' => $languageCode,
|
|
||||||
'error' => $e->getMessage()
|
|
||||||
]);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,24 @@
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Events\ArticleFetched;
|
|
||||||
use App\Events\ArticleReadyToPublish;
|
|
||||||
use App\Events\ExceptionOccurred;
|
use App\Events\ExceptionOccurred;
|
||||||
use App\Listeners\ValidateArticle;
|
|
||||||
use App\Listeners\LogExceptionToDatabase;
|
use App\Listeners\LogExceptionToDatabase;
|
||||||
use App\Listeners\PublishArticle;
|
|
||||||
use App\LogLevelEnum;
|
use App\LogLevelEnum;
|
||||||
|
use Error;
|
||||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use InvalidArgumentException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
|
||||||
Event::listen(
|
Event::listen(
|
||||||
ExceptionOccurred::class,
|
ExceptionOccurred::class,
|
||||||
LogExceptionToDatabase::class,
|
LogExceptionToDatabase::class,
|
||||||
|
|
@ -40,9 +36,8 @@ public function boot(): void
|
||||||
private function mapExceptionToLogLevel(Throwable $exception): LogLevelEnum
|
private function mapExceptionToLogLevel(Throwable $exception): LogLevelEnum
|
||||||
{
|
{
|
||||||
return match (true) {
|
return match (true) {
|
||||||
$exception instanceof \Error => LogLevelEnum::CRITICAL,
|
$exception instanceof Error => LogLevelEnum::CRITICAL,
|
||||||
$exception instanceof \RuntimeException => LogLevelEnum::ERROR,
|
$exception instanceof InvalidArgumentException => LogLevelEnum::WARNING,
|
||||||
$exception instanceof \InvalidArgumentException => LogLevelEnum::WARNING,
|
|
||||||
default => LogLevelEnum::ERROR,
|
default => LogLevelEnum::ERROR,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,34 +3,68 @@
|
||||||
namespace App\Services\Article;
|
namespace App\Services\Article;
|
||||||
|
|
||||||
use App\Models\Article;
|
use App\Models\Article;
|
||||||
|
use App\Models\Feed;
|
||||||
use App\Services\Http\HttpFetcher;
|
use App\Services\Http\HttpFetcher;
|
||||||
use App\Services\Factories\ArticleParserFactory;
|
use App\Services\Factories\ArticleParserFactory;
|
||||||
use App\Services\Factories\HomepageParserFactory;
|
use App\Services\Factories\HomepageParserFactory;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class ArticleFetcher
|
class ArticleFetcher
|
||||||
{
|
{
|
||||||
public static function getNewArticles(): Collection
|
public static function getArticlesFromFeed(Feed $feed): Collection
|
||||||
|
{
|
||||||
|
if ($feed->type === 'rss') {
|
||||||
|
return self::getArticlesFromRssFeed($feed);
|
||||||
|
} elseif ($feed->type === 'website') {
|
||||||
|
return self::getArticlesFromWebsiteFeed($feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
LogSaver::warning("Unsupported feed type", null, [
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'feed_type' => $feed->type
|
||||||
|
]);
|
||||||
|
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getArticlesFromRssFeed(Feed $feed): Collection
|
||||||
|
{
|
||||||
|
// TODO: Implement RSS feed parsing
|
||||||
|
// For now, return empty collection
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function getArticlesFromWebsiteFeed(Feed $feed): Collection
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$allArticles = collect();
|
// Try to get parser for this feed
|
||||||
|
$parser = HomepageParserFactory::getParserForFeed($feed);
|
||||||
|
|
||||||
foreach (HomepageParserFactory::getAllParsers() as $parser) {
|
if (! $parser) {
|
||||||
$html = HttpFetcher::fetchHtml($parser->getHomepageUrl());
|
LogSaver::warning("No parser available for feed URL", null, [
|
||||||
$urls = $parser->extractArticleUrls($html);
|
'feed_id' => $feed->id,
|
||||||
|
'feed_url' => $feed->url
|
||||||
|
]);
|
||||||
|
|
||||||
$articles = collect($urls)
|
return collect();
|
||||||
->map(fn (string $url) => self::saveArticle($url));
|
|
||||||
|
|
||||||
$allArticles = $allArticles->merge($articles);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $allArticles->filter();
|
$html = HttpFetcher::fetchHtml($feed->url);
|
||||||
} catch (Exception $e) {
|
$urls = $parser->extractArticleUrls($html);
|
||||||
logger()->error("Failed to get new articles", ['error' => $e->getMessage()]);
|
|
||||||
|
|
||||||
return new Collection([]);
|
return collect($urls)
|
||||||
|
->map(fn (string $url) => self::saveArticle($url, $feed->id));
|
||||||
|
|
||||||
|
} catch (Exception $e) {
|
||||||
|
LogSaver::error("Failed to fetch articles from website feed", null, [
|
||||||
|
'feed_id' => $feed->id,
|
||||||
|
'feed_url' => $feed->url,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return collect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +76,7 @@ public static function fetchArticleData(Article $article): array
|
||||||
|
|
||||||
return $parser->extractData($html);
|
return $parser->extractData($html);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
logger()->error('Exception while fetching article data', [
|
LogSaver::error('Exception while fetching article data', null, [
|
||||||
'url' => $article->url,
|
'url' => $article->url,
|
||||||
'error' => $e->getMessage()
|
'error' => $e->getMessage()
|
||||||
]);
|
]);
|
||||||
|
|
@ -51,7 +85,7 @@ public static function fetchArticleData(Article $article): array
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function saveArticle(string $url): Article
|
private static function saveArticle(string $url, ?int $feedId = null): Article
|
||||||
{
|
{
|
||||||
$existingArticle = Article::where('url', $url)->first();
|
$existingArticle = Article::where('url', $url)->first();
|
||||||
|
|
||||||
|
|
@ -59,6 +93,9 @@ private static function saveArticle(string $url): Article
|
||||||
return $existingArticle;
|
return $existingArticle;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Article::create(['url' => $url]);
|
return Article::create([
|
||||||
|
'url' => $url,
|
||||||
|
'feed_id' => $feedId
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Services\Factories;
|
namespace App\Services\Factories;
|
||||||
|
|
||||||
use App\Contracts\HomepageParserInterface;
|
use App\Contracts\HomepageParserInterface;
|
||||||
|
use App\Models\Feed;
|
||||||
use App\Services\Parsers\VrtHomepageParserAdapter;
|
use App\Services\Parsers\VrtHomepageParserAdapter;
|
||||||
use App\Services\Parsers\BelgaHomepageParserAdapter;
|
use App\Services\Parsers\BelgaHomepageParserAdapter;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
|
@ -14,6 +15,9 @@ class HomepageParserFactory
|
||||||
BelgaHomepageParserAdapter::class,
|
BelgaHomepageParserAdapter::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
public static function getParser(string $url): HomepageParserInterface
|
public static function getParser(string $url): HomepageParserInterface
|
||||||
{
|
{
|
||||||
foreach (self::$parsers as $parserClass) {
|
foreach (self::$parsers as $parserClass) {
|
||||||
|
|
@ -27,23 +31,12 @@ public static function getParser(string $url): HomepageParserInterface
|
||||||
throw new Exception("No homepage parser found for URL: {$url}");
|
throw new Exception("No homepage parser found for URL: {$url}");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getAllParsers(): array
|
public static function getParserForFeed(Feed $feed): ?HomepageParserInterface
|
||||||
{
|
{
|
||||||
return array_map(fn($parserClass) => new $parserClass(), self::$parsers);
|
try {
|
||||||
}
|
return self::getParser($feed->url);
|
||||||
|
} catch (Exception) {
|
||||||
public static function getSupportedSources(): array
|
return null;
|
||||||
{
|
|
||||||
return array_map(function($parserClass) {
|
|
||||||
$parser = new $parserClass();
|
|
||||||
return $parser->getSourceName();
|
|
||||||
}, self::$parsers);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function registerParser(string $parserClass): void
|
|
||||||
{
|
|
||||||
if (!in_array($parserClass, self::$parsers)) {
|
|
||||||
self::$parsers[] = $parserClass;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
50
app/Services/Log/LogSaver.php
Normal file
50
app/Services/Log/LogSaver.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Log;
|
||||||
|
|
||||||
|
use App\LogLevelEnum;
|
||||||
|
use App\Models\Log;
|
||||||
|
use App\Models\PlatformChannel;
|
||||||
|
|
||||||
|
class LogSaver
|
||||||
|
{
|
||||||
|
public static function info(string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||||
|
{
|
||||||
|
self::log(LogLevelEnum::INFO, $message, $channel, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function error(string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||||
|
{
|
||||||
|
self::log(LogLevelEnum::ERROR, $message, $channel, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function warning(string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||||
|
{
|
||||||
|
self::log(LogLevelEnum::WARNING, $message, $channel, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function debug(string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||||
|
{
|
||||||
|
self::log(LogLevelEnum::DEBUG, $message, $channel, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function log(LogLevelEnum $level, string $message, ?PlatformChannel $channel = null, array $context = []): void
|
||||||
|
{
|
||||||
|
$logContext = $context;
|
||||||
|
|
||||||
|
if ($channel) {
|
||||||
|
$logContext = array_merge($logContext, [
|
||||||
|
'channel_id' => $channel->id,
|
||||||
|
'channel_name' => $channel->name,
|
||||||
|
'platform' => $channel->platformInstance->platform->value,
|
||||||
|
'instance_url' => $channel->platformInstance->url,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::create([
|
||||||
|
'level' => $level,
|
||||||
|
'message' => $message,
|
||||||
|
'context' => $logContext,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
app/Services/Publishing/ArticlePublishingService.php
Normal file
76
app/Services/Publishing/ArticlePublishingService.php
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Publishing;
|
||||||
|
|
||||||
|
use App\Enums\PlatformEnum;
|
||||||
|
use App\Exceptions\PublishException;
|
||||||
|
use App\Models\Article;
|
||||||
|
use App\Models\ArticlePublication;
|
||||||
|
use App\Modules\Lemmy\Services\LemmyPublisher;
|
||||||
|
use App\Services\Log\LogSaver;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class ArticlePublishingService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws PublishException
|
||||||
|
*/
|
||||||
|
public function publishToRoutedChannels(Article $article, array $extractedData): Collection
|
||||||
|
{
|
||||||
|
if (! $article->is_valid) {
|
||||||
|
throw new PublishException($article, PlatformEnum::LEMMY, new RuntimeException('CANNOT_PUBLISH_INVALID_ARTICLE'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$feed = $article->feed;
|
||||||
|
$activeChannels = $feed->activeChannels()->with(['platformInstance', 'platformAccounts'])->get();
|
||||||
|
|
||||||
|
return $activeChannels->map(function ($channel) use ($article, $extractedData) {
|
||||||
|
$account = $channel->platformAccounts()->where('is_active', true)->first();
|
||||||
|
|
||||||
|
if (! $account) {
|
||||||
|
LogSaver::warning('No active account for channel', $channel, [
|
||||||
|
'article_id' => $article->id
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->publishToChannel($article, $extractedData, $channel, $account);
|
||||||
|
})
|
||||||
|
->filter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function publishToChannel(Article $article, array $extractedData, $channel, $account): ?ArticlePublication
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$publisher = new LemmyPublisher($account);
|
||||||
|
$postData = $publisher->publishToChannel($article, $extractedData, $channel);
|
||||||
|
|
||||||
|
$publication = ArticlePublication::create([
|
||||||
|
'article_id' => $article->id,
|
||||||
|
'post_id' => $postData['post_view']['post']['id'],
|
||||||
|
'community_id' => $channel->channel_id,
|
||||||
|
'published_by' => $account->username,
|
||||||
|
'published_at' => now(),
|
||||||
|
'platform' => $channel->platformInstance->platform->value,
|
||||||
|
'publication_data' => $postData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
LogSaver::info('Published to channel via routing', $channel, [
|
||||||
|
'article_id' => $article->id,
|
||||||
|
'priority' => $channel->pivot->priority
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $publication;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
LogSaver::warning('Failed to publish to channel', $channel, [
|
||||||
|
'article_id' => $article->id,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Services/RoutingValidationService.php
Normal file
30
app/Services/RoutingValidationService.php
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Exceptions\RoutingMismatchException;
|
||||||
|
use App\Models\Feed;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class RoutingValidationService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @throws RoutingMismatchException
|
||||||
|
*/
|
||||||
|
public function validateLanguageCompatibility(Feed $feed, Collection $channels): void
|
||||||
|
{
|
||||||
|
if (! $feed->language) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($channels as $channel) {
|
||||||
|
if (! $channel->language) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($feed->language !== $channel->language) {
|
||||||
|
throw new RoutingMismatchException($feed, $channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
'username' => env('LEMMY_USERNAME'),
|
|
||||||
'password' => env('LEMMY_PASSWORD'),
|
|
||||||
'instance' => env('LEMMY_INSTANCE'),
|
|
||||||
'community' => env('LEMMY_COMMUNITY'),
|
|
||||||
'community_id' => env('LEMMY_COMMUNITY_ID'),
|
|
||||||
];
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('feed_platform_channels', function (Blueprint $table) {
|
||||||
|
$table->foreignId('feed_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->foreignId('platform_channel_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->integer('priority')->default(0); // for ordering/priority
|
||||||
|
$table->json('filters')->nullable(); // keyword filters, content filters, etc.
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->primary(['feed_id', 'platform_channel_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('feed_platform_channels');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('articles', function (Blueprint $table) {
|
||||||
|
$table->foreignId('feed_id')->nullable()->constrained()->onDelete('cascade');
|
||||||
|
$table->index(['feed_id', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('articles', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['feed_id', 'created_at']);
|
||||||
|
$table->dropForeign(['feed_id']);
|
||||||
|
$table->dropColumn('feed_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('platform_channels', function (Blueprint $table) {
|
||||||
|
$table->string('language', 2)->nullable()->after('description');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('platform_channels', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('language');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('languages', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('short_code', 2)->unique(); // ISO 639-1 language code (en, fr, de, etc.)
|
||||||
|
$table->string('name'); // English name (English, French, German, etc.)
|
||||||
|
$table->string('native_name')->nullable(); // Native name (English, Français, Deutsch, etc.)
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('languages');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('language_platform_instance', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('language_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->foreignId('platform_instance_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->integer('platform_language_id')->nullable(); // The platform-specific ID (e.g., Lemmy's language ID)
|
||||||
|
$table->boolean('is_default')->default(false); // Whether this is the default language for this instance
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['language_id', 'platform_instance_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('language_platform_instance');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('feeds', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('language');
|
||||||
|
$table->foreignId('language_id')->nullable()->constrained();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('feeds', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['language_id']);
|
||||||
|
$table->dropColumn('language_id');
|
||||||
|
$table->string('language', 2)->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('platform_channels', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('language');
|
||||||
|
$table->foreignId('language_id')->nullable()->constrained();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('platform_channels', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['language_id']);
|
||||||
|
$table->dropColumn('language_id');
|
||||||
|
$table->string('language', 2)->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
127
resources/views/pages/routing/create.blade.php
Normal file
127
resources/views/pages/routing/create.blade.php
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="max-w-2xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div class="px-4 py-6 sm:px-0">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900">Create Feed Routing</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Route a feed to one or more channels.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<form action="{{ route('routing.store') }}" method="POST" class="px-4 py-5 sm:p-6">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<div>
|
||||||
|
<label for="feed_id" class="block text-sm font-medium text-gray-700">Feed</label>
|
||||||
|
<select name="feed_id"
|
||||||
|
id="feed_id"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('feed_id') border-red-300 @enderror">
|
||||||
|
<option value="">Select a feed...</option>
|
||||||
|
@foreach($feeds as $feed)
|
||||||
|
<option value="{{ $feed->id }}" {{ old('feed_id') == $feed->id ? 'selected' : '' }}>
|
||||||
|
{{ $feed->name }} ({{ $feed->type_display }})
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
@error('feed_id')
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-3">Target Channels</label>
|
||||||
|
<div class="space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-md p-3">
|
||||||
|
@forelse($channels as $channel)
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="channel_ids[]"
|
||||||
|
value="{{ $channel->id }}"
|
||||||
|
id="channel_{{ $channel->id }}"
|
||||||
|
{{ in_array($channel->id, old('channel_ids', [])) ? 'checked' : '' }}
|
||||||
|
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
|
||||||
|
<label for="channel_{{ $channel->id }}" class="ml-2 block text-sm text-gray-900">
|
||||||
|
<span class="font-medium">{{ $channel->name }}</span>
|
||||||
|
<span class="text-gray-500">({{ $channel->platformInstance->name }})</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="text-sm text-gray-500">No active channels available. Please create channels first.</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
@error('channel_ids')
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
@error('channel_ids.*')
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="priority" class="block text-sm font-medium text-gray-700">Priority</label>
|
||||||
|
<input type="number"
|
||||||
|
name="priority"
|
||||||
|
id="priority"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value="{{ old('priority', 0) }}"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('priority') border-red-300 @enderror"
|
||||||
|
placeholder="0">
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Higher numbers = higher priority (0-100)</p>
|
||||||
|
@error('priority')
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="filters" class="block text-sm font-medium text-gray-700">Filters (Optional)</label>
|
||||||
|
<textarea name="filters"
|
||||||
|
id="filters"
|
||||||
|
rows="4"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('filters') border-red-300 @enderror"
|
||||||
|
placeholder='{"keywords": ["technology", "AI"], "exclude_keywords": ["sports"]}'>{{ old('filters') }}</textarea>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">JSON format for content filtering rules</p>
|
||||||
|
@error('filters')
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center justify-end space-x-3">
|
||||||
|
<a href="{{ route('routing.index') }}" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="bg-indigo-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Create Routing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($feeds->isEmpty() || $channels->isEmpty())
|
||||||
|
<div class="mt-6 bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-exclamation-triangle text-yellow-400"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-yellow-800">Prerequisites Missing</h3>
|
||||||
|
<div class="mt-2 text-sm text-yellow-700">
|
||||||
|
<p>To create routing, you need:</p>
|
||||||
|
<ul class="list-disc list-inside mt-1">
|
||||||
|
@if($feeds->isEmpty())
|
||||||
|
<li>At least one active <a href="{{ route('feeds.create') }}" class="underline">feed</a></li>
|
||||||
|
@endif
|
||||||
|
@if($channels->isEmpty())
|
||||||
|
<li>At least one active <a href="{{ route('channels.create') }}" class="underline">channel</a></li>
|
||||||
|
@endif
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
131
resources/views/pages/routing/edit.blade.php
Normal file
131
resources/views/pages/routing/edit.blade.php
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="max-w-2xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div class="px-4 py-6 sm:px-0">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900">Edit Routing</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">
|
||||||
|
{{ $feed->name }} → {{ $channel->name }} ({{ $channel->platformInstance->name }})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<form action="{{ route('routing.update', [$feed, $channel]) }}" method="POST" class="px-4 py-5 sm:p-6">
|
||||||
|
@csrf
|
||||||
|
@method('PUT')
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Routing Details</h3>
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-700">Feed:</span>
|
||||||
|
<div class="flex items-center mt-1">
|
||||||
|
@if($feed->type === 'rss')
|
||||||
|
<i class="fas fa-rss text-orange-500 mr-2"></i>
|
||||||
|
@else
|
||||||
|
<i class="fas fa-globe text-blue-500 mr-2"></i>
|
||||||
|
@endif
|
||||||
|
{{ $feed->name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-700">Channel:</span>
|
||||||
|
<div class="flex items-center mt-1">
|
||||||
|
<i class="fas fa-hashtag text-gray-400 mr-2"></i>
|
||||||
|
{{ $channel->name }}
|
||||||
|
<span class="ml-2 text-gray-500">({{ $channel->platformInstance->name }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="is_active"
|
||||||
|
id="is_active"
|
||||||
|
value="1"
|
||||||
|
{{ old('is_active', $routing->pivot->is_active) ? 'checked' : '' }}
|
||||||
|
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
|
||||||
|
<label for="is_active" class="ml-2 block text-sm text-gray-900">
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="priority" class="block text-sm font-medium text-gray-700">Priority</label>
|
||||||
|
<input type="number"
|
||||||
|
name="priority"
|
||||||
|
id="priority"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value="{{ old('priority', $routing->pivot->priority) }}"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('priority') border-red-300 @enderror"
|
||||||
|
placeholder="0">
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Higher numbers = higher priority (0-100)</p>
|
||||||
|
@error('priority')
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="filters" class="block text-sm font-medium text-gray-700">Filters</label>
|
||||||
|
<textarea name="filters"
|
||||||
|
id="filters"
|
||||||
|
rows="6"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm @error('filters') border-red-300 @enderror"
|
||||||
|
placeholder='{"keywords": ["technology", "AI"], "exclude_keywords": ["sports"]}'>{{ old('filters', $routing->pivot->filters ? json_encode($routing->pivot->filters, JSON_PRETTY_PRINT) : '') }}</textarea>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">JSON format for content filtering rules</p>
|
||||||
|
@error('filters')
|
||||||
|
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-info-circle text-blue-400"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-blue-800">Filter Examples</h3>
|
||||||
|
<div class="mt-2 text-sm text-blue-700">
|
||||||
|
<p>You can use filters to control which content gets routed:</p>
|
||||||
|
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||||
|
<li><code>{"keywords": ["tech", "AI"]}</code> - Include only articles with these keywords</li>
|
||||||
|
<li><code>{"exclude_keywords": ["sports"]}</code> - Exclude articles with these keywords</li>
|
||||||
|
<li><code>{"min_length": 500}</code> - Minimum article length</li>
|
||||||
|
<li><code>{"max_age_hours": 24}</code> - Only articles from last 24 hours</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center justify-between">
|
||||||
|
<form action="{{ route('routing.destroy', [$feed, $channel]) }}" method="POST" class="inline"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete this routing?')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="bg-red-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-red-700">
|
||||||
|
Delete Routing
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<a href="{{ route('routing.index') }}" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="bg-indigo-600 py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Update Routing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
151
resources/views/pages/routing/index.blade.php
Normal file
151
resources/views/pages/routing/index.blade.php
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div class="px-4 py-6 sm:px-0">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900">Feed Routing</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-600">Manage how feeds are routed to channels</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('routing.create') }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Create New Routing
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
||||||
|
{{ session('success') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Feeds Section -->
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">Feeds → Channels</h3>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm text-gray-500">Active feed-to-channel routing</p>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-200">
|
||||||
|
@forelse($feeds as $feed)
|
||||||
|
@if($feed->channels->count() > 0)
|
||||||
|
<div class="px-4 py-4">
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
@if($feed->type === 'rss')
|
||||||
|
<i class="fas fa-rss text-orange-500 mr-2"></i>
|
||||||
|
@else
|
||||||
|
<i class="fas fa-globe text-blue-500 mr-2"></i>
|
||||||
|
@endif
|
||||||
|
<span class="font-medium text-gray-900">{{ $feed->name }}</span>
|
||||||
|
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
{{ $feed->channels->count() }} channel{{ $feed->channels->count() !== 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
@foreach($feed->channels as $channel)
|
||||||
|
<div class="flex items-center justify-between bg-gray-50 rounded px-3 py-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-hashtag text-gray-400 mr-2"></i>
|
||||||
|
<span class="text-sm text-gray-900">{{ $channel->name }}</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-500">({{ $channel->platformInstance->name }})</span>
|
||||||
|
@if(!$channel->pivot->is_active)
|
||||||
|
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@if($channel->pivot->priority > 0)
|
||||||
|
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
Priority: {{ $channel->pivot->priority }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<form action="{{ route('routing.toggle', [$feed, $channel]) }}" method="POST" class="inline">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="text-xs px-2 py-1 rounded {{ $channel->pivot->is_active ? 'bg-red-100 text-red-800 hover:bg-red-200' : 'bg-green-100 text-green-800 hover:bg-green-200' }}">
|
||||||
|
{{ $channel->pivot->is_active ? 'Deactivate' : 'Activate' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a href="{{ route('routing.edit', [$feed, $channel]) }}" class="text-indigo-600 hover:text-indigo-900 text-xs">Edit</a>
|
||||||
|
<form action="{{ route('routing.destroy', [$feed, $channel]) }}" method="POST" class="inline"
|
||||||
|
onsubmit="return confirm('Remove this routing?')">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-900 text-xs">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@empty
|
||||||
|
<div class="px-4 py-8 text-center text-gray-500">
|
||||||
|
No feed routing configured
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Channels Section -->
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">Channels ← Feeds</h3>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm text-gray-500">Channels and their connected feeds</p>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-200">
|
||||||
|
@forelse($channels as $channel)
|
||||||
|
@if($channel->feeds->count() > 0)
|
||||||
|
<div class="px-4 py-4">
|
||||||
|
<div class="flex items-center mb-3">
|
||||||
|
<i class="fas fa-hashtag text-gray-400 mr-2"></i>
|
||||||
|
<span class="font-medium text-gray-900">{{ $channel->name }}</span>
|
||||||
|
<span class="ml-2 text-xs text-gray-500">({{ $channel->platformInstance->name }})</span>
|
||||||
|
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
{{ $channel->feeds->count() }} feed{{ $channel->feeds->count() !== 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
@foreach($channel->feeds as $feed)
|
||||||
|
<div class="flex items-center justify-between bg-gray-50 rounded px-3 py-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
@if($feed->type === 'rss')
|
||||||
|
<i class="fas fa-rss text-orange-500 mr-2"></i>
|
||||||
|
@else
|
||||||
|
<i class="fas fa-globe text-blue-500 mr-2"></i>
|
||||||
|
@endif
|
||||||
|
<span class="text-sm text-gray-900">{{ $feed->name }}</span>
|
||||||
|
@if(!$feed->pivot->is_active)
|
||||||
|
<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('routing.edit', [$feed, $channel]) }}" class="text-indigo-600 hover:text-indigo-900 text-xs">Edit</a>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@empty
|
||||||
|
<div class="px-4 py-8 text-center text-gray-500">
|
||||||
|
No channels with feeds
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($feeds->where('channels')->isEmpty() && $channels->where('feeds')->isEmpty())
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<i class="fas fa-route text-gray-400 text-6xl mb-4"></i>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No routing configured</h3>
|
||||||
|
<p class="text-gray-500 mb-4">Connect your feeds to channels to start routing content.</p>
|
||||||
|
<a href="{{ route('routing.create') }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
Create First Routing
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
@ -21,6 +21,10 @@
|
||||||
<i class="fas fa-rss mr-3"></i>
|
<i class="fas fa-rss mr-3"></i>
|
||||||
Feeds
|
Feeds
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/routing" class="flex items-center px-4 py-3 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors {{ request()->is('routing*') ? 'bg-gray-700 text-white' : '' }}">
|
||||||
|
<i class="fas fa-route mr-3"></i>
|
||||||
|
Routing
|
||||||
|
</a>
|
||||||
<a href="/logs" class="flex items-center px-4 py-3 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors {{ request()->is('logs') ? 'bg-gray-700 text-white' : '' }}">
|
<a href="/logs" class="flex items-center px-4 py-3 text-gray-300 hover:bg-gray-700 hover:text-white transition-colors {{ request()->is('logs') ? 'bg-gray-700 text-white' : '' }}">
|
||||||
<i class="fas fa-list mr-3"></i>
|
<i class="fas fa-list mr-3"></i>
|
||||||
Logs
|
Logs
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,11 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Console\Commands\FetchNewArticlesCommand;
|
use App\Console\Commands\FetchNewArticlesCommand;
|
||||||
use App\Enums\PlatformEnum;
|
|
||||||
use App\Jobs\SyncChannelPostsJob;
|
use App\Jobs\SyncChannelPostsJob;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
|
|
||||||
Schedule::command(FetchNewArticlesCommand::class)->hourly();
|
Schedule::command(FetchNewArticlesCommand::class)->hourly();
|
||||||
|
|
||||||
Schedule::call(function () {
|
Schedule::call(function () {
|
||||||
$communityId = config('lemmy.community_id');
|
SyncChannelPostsJob::dispatchForAllActiveChannels();
|
||||||
$communityName = config('lemmy.community');
|
|
||||||
|
|
||||||
if ($communityId && $communityName) {
|
|
||||||
SyncChannelPostsJob::dispatch(
|
|
||||||
PlatformEnum::LEMMY,
|
|
||||||
$communityId,
|
|
||||||
$communityName
|
|
||||||
);
|
|
||||||
|
|
||||||
logger()->info('Dispatched channel posts sync job', [
|
|
||||||
'platform' => 'lemmy',
|
|
||||||
'community_id' => $communityId,
|
|
||||||
'community_name' => $communityName
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
logger()->warning('Missing Lemmy community configuration for sync job');
|
|
||||||
}
|
|
||||||
})->everyTenMinutes()->name('sync-lemmy-channel-posts');
|
})->everyTenMinutes()->name('sync-lemmy-channel-posts');
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,11 @@
|
||||||
|
|
||||||
Route::resource('channels', App\Http\Controllers\PlatformChannelsController::class)->names('channels');
|
Route::resource('channels', App\Http\Controllers\PlatformChannelsController::class)->names('channels');
|
||||||
Route::resource('feeds', App\Http\Controllers\FeedsController::class)->names('feeds');
|
Route::resource('feeds', App\Http\Controllers\FeedsController::class)->names('feeds');
|
||||||
|
|
||||||
|
Route::get('/routing', [App\Http\Controllers\RoutingController::class, 'index'])->name('routing.index');
|
||||||
|
Route::get('/routing/create', [App\Http\Controllers\RoutingController::class, 'create'])->name('routing.create');
|
||||||
|
Route::post('/routing', [App\Http\Controllers\RoutingController::class, 'store'])->name('routing.store');
|
||||||
|
Route::get('/routing/{feed}/{channel}/edit', [App\Http\Controllers\RoutingController::class, 'edit'])->name('routing.edit');
|
||||||
|
Route::put('/routing/{feed}/{channel}', [App\Http\Controllers\RoutingController::class, 'update'])->name('routing.update');
|
||||||
|
Route::delete('/routing/{feed}/{channel}', [App\Http\Controllers\RoutingController::class, 'destroy'])->name('routing.destroy');
|
||||||
|
Route::post('/routing/{feed}/{channel}/toggle', [App\Http\Controllers\RoutingController::class, 'toggle'])->name('routing.toggle');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue