Initial baseline: LaraBlog core with plugin commerce surface.
Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Domain\Plugin\PluginManager;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PluginsSyncCommand extends Command
|
||||
{
|
||||
protected $signature = 'plugins:sync {--enable= : Comma-separated plugin names to enable after sync}';
|
||||
|
||||
protected $description = 'Discover plugins on disk and sync into the plugins table';
|
||||
|
||||
public function handle(PluginManager $manager): int
|
||||
{
|
||||
$discovered = $manager->syncDiscoveredPlugins();
|
||||
$this->info('Synced '.$discovered->count().' plugins.');
|
||||
|
||||
foreach ($discovered as $name => $manifest) {
|
||||
$this->line('- '.$name.' v'.($manifest['version'] ?? '1.0.0'));
|
||||
}
|
||||
|
||||
$enable = trim((string) $this->option('enable'));
|
||||
if ($enable !== '') {
|
||||
foreach (array_filter(array_map('trim', explode(',', $enable))) as $name) {
|
||||
$manager->enable($name);
|
||||
$this->info("Enabled: {$name}");
|
||||
}
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Simple alternative to workerman:ai for local/dev: drain AI queues once or loop.
|
||||
*/
|
||||
class QueueAiWorkCommand extends Command
|
||||
{
|
||||
protected $signature = 'queue:ai
|
||||
{--once : Process available jobs once and exit}
|
||||
{--max-time=60 : Max seconds when looping}';
|
||||
|
||||
protected $description = 'Process ai-content and ai-moderation queues (dev-friendly alternative to workerman:ai)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$params = [
|
||||
'--queue' => 'ai-content,ai-moderation',
|
||||
'--tries' => 3,
|
||||
'--sleep' => 1,
|
||||
];
|
||||
|
||||
if ($this->option('once')) {
|
||||
$params['--stop-when-empty'] = true;
|
||||
$params['--max-jobs'] = 50;
|
||||
} else {
|
||||
$params['--max-time'] = (int) $this->option('max-time');
|
||||
}
|
||||
|
||||
return $this->call('queue:work', $params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Domain\Blog\ContentFormat;
|
||||
use App\Domain\Blog\ContentRenderer;
|
||||
use App\Models\Article;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Category;
|
||||
use App\Models\Comment;
|
||||
use App\Models\Link;
|
||||
use App\Models\Stylevar;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class SablogImportCommand extends Command
|
||||
{
|
||||
protected $signature = 'sablog:import
|
||||
{--connection=sablog : Laravel DB connection name for the source sablog database}
|
||||
{--prefix=sablog_ : Source table prefix}
|
||||
{--attachments= : Absolute path to sablog attachments directory}
|
||||
{--mode=raw : Import mode: raw (keep HTML) or markdown (convert + rewrite attach embeds)}
|
||||
{--encoding=auto : utf8|gbk|auto}
|
||||
{--disk=attachments : Target attachments disk}
|
||||
{--attachments-url-prefix=attachments : Legacy URL prefix}
|
||||
{--retry-failed : Retry attachment rows that failed previously}
|
||||
{--dry-run : Do not write to the target database or object storage}';
|
||||
|
||||
protected $description = 'Import sablog content. Modes: raw (HTML) or markdown (converted).';
|
||||
|
||||
/** @var array<string, int> */
|
||||
protected array $report = [
|
||||
'users' => 0,
|
||||
'categories' => 0,
|
||||
'articles' => 0,
|
||||
'comments' => 0,
|
||||
'tags' => 0,
|
||||
'links' => 0,
|
||||
'stylevars' => 0,
|
||||
'attachments_ok' => 0,
|
||||
'attachments_missing' => 0,
|
||||
'attachments_failed' => 0,
|
||||
'skipped_trackbacks' => 0,
|
||||
'skipped_searchindex' => 0,
|
||||
'skipped_sessions' => 0,
|
||||
];
|
||||
|
||||
public function handle(ContentRenderer $renderer): int
|
||||
{
|
||||
$mode = strtolower((string) $this->option('mode'));
|
||||
if (! in_array($mode, ['raw', 'markdown'], true)) {
|
||||
$this->error('--mode must be raw or markdown');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$connection = (string) $this->option('connection');
|
||||
$prefix = (string) $this->option('prefix');
|
||||
$attachmentsRoot = $this->option('attachments');
|
||||
$disk = (string) $this->option('disk');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$encoding = (string) $this->option('encoding');
|
||||
|
||||
if (! config("database.connections.{$connection}")) {
|
||||
$this->error("Database connection [{$connection}] is not configured. Add it to config/database.php / .env (SABLOG_DB_*).");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Import mode: {$mode}".($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
try {
|
||||
$source = DB::connection($connection);
|
||||
$source->select('select 1');
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Cannot connect to sablog database: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->reportSkipped($source, $prefix);
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::transaction(function () use ($source, $prefix, $encoding, $mode, $renderer, $attachmentsRoot, $disk) {
|
||||
$this->importUsers($source, $prefix, $encoding);
|
||||
$this->importCategories($source, $prefix, $encoding);
|
||||
$this->importArticles($source, $prefix, $encoding, $mode, $renderer);
|
||||
$this->importComments($source, $prefix, $encoding);
|
||||
$this->importTags($source, $prefix, $encoding);
|
||||
$this->importLinks($source, $prefix, $encoding);
|
||||
$this->importStylevars($source, $prefix, $encoding);
|
||||
$this->importAttachments($source, $prefix, $attachmentsRoot, $disk);
|
||||
});
|
||||
} else {
|
||||
$this->importUsers($source, $prefix, $encoding, true);
|
||||
$this->importCategories($source, $prefix, $encoding, true);
|
||||
$this->importArticles($source, $prefix, $encoding, $mode, $renderer, true);
|
||||
$this->countTable($source, $prefix.'comments', 'comments');
|
||||
$this->countTable($source, $prefix.'tags', 'tags');
|
||||
$this->countTable($source, $prefix.'links', 'links');
|
||||
$this->countTable($source, $prefix.'stylevars', 'stylevars');
|
||||
$this->countTable($source, $prefix.'attachments', 'attachments_ok');
|
||||
}
|
||||
|
||||
$this->table(array_keys($this->report), [array_values($this->report)]);
|
||||
$this->info('Done. Source database/files were not modified.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
protected function reportSkipped($source, string $prefix): void
|
||||
{
|
||||
foreach ([
|
||||
'trackbacks' => 'skipped_trackbacks',
|
||||
'trackbacklog' => 'skipped_trackbacks',
|
||||
'searchindex' => 'skipped_searchindex',
|
||||
'sessions' => 'skipped_sessions',
|
||||
] as $table => $key) {
|
||||
$name = $prefix.$table;
|
||||
if ($this->sourceTableExists($source, $name)) {
|
||||
$this->report[$key] += (int) $source->table($name)->count();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function importUsers($source, string $prefix, string $encoding, bool $countOnly = false): void
|
||||
{
|
||||
$table = $prefix.'users';
|
||||
if (! $this->sourceTableExists($source, $table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($source->table($table)->orderBy('userid')->cursor() as $row) {
|
||||
$this->report['users']++;
|
||||
if ($countOnly) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->upsertWithId(User::class, (int) $row->userid, [
|
||||
'name' => $this->decode((string) $row->username, $encoding),
|
||||
'username' => $this->decode((string) $row->username, $encoding),
|
||||
'email' => null,
|
||||
'password' => Hash::make(Str::password(32)),
|
||||
'password_legacy' => (string) $row->password,
|
||||
'url' => $this->decode((string) ($row->url ?? ''), $encoding) ?: null,
|
||||
'login_count' => (int) ($row->logincount ?? 0),
|
||||
'login_ip' => $row->loginip ?? null,
|
||||
'login_at' => $this->fromUnix($row->logintime ?? null),
|
||||
'reg_ip' => $row->regip ?? null,
|
||||
'created_at' => $this->fromUnix($row->regdateline ?? null) ?? now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function importCategories($source, string $prefix, string $encoding, bool $countOnly = false): void
|
||||
{
|
||||
$table = $prefix.'categories';
|
||||
if (! $this->sourceTableExists($source, $table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($source->table($table)->orderBy('cid')->cursor() as $row) {
|
||||
$this->report['categories']++;
|
||||
if ($countOnly) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->upsertWithId(Category::class, (int) $row->cid, [
|
||||
'name' => $this->decode((string) $row->name, $encoding),
|
||||
'display_order' => (int) ($row->displayorder ?? 0),
|
||||
'articles_count' => (int) ($row->articles ?? 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function importArticles($source, string $prefix, string $encoding, string $mode, ContentRenderer $renderer, bool $countOnly = false): void
|
||||
{
|
||||
$table = $prefix.'articles';
|
||||
if (! $this->sourceTableExists($source, $table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($source->table($table)->orderBy('articleid')->cursor() as $row) {
|
||||
$this->report['articles']++;
|
||||
if ($countOnly) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = $this->decode((string) $row->content, $encoding);
|
||||
$format = ContentFormat::HTML;
|
||||
|
||||
if ($mode === 'markdown') {
|
||||
$content = $renderer->convertImportedHtmlToMarkdown($content, (int) $row->articleid);
|
||||
$format = ContentFormat::MARKDOWN;
|
||||
}
|
||||
|
||||
$legacyAttachments = null;
|
||||
if (! empty($row->attachments)) {
|
||||
$legacyAttachments = @unserialize($row->attachments);
|
||||
if ($legacyAttachments === false) {
|
||||
$legacyAttachments = ['raw' => $row->attachments];
|
||||
}
|
||||
}
|
||||
|
||||
$this->upsertWithId(Article::class, (int) $row->articleid, [
|
||||
'category_id' => (int) $row->cid,
|
||||
'user_id' => (int) $row->uid,
|
||||
'title' => $this->decode((string) $row->title, $encoding),
|
||||
'content' => $content,
|
||||
'content_format' => $format,
|
||||
'description' => $this->decode((string) ($row->description ?? ''), $encoding) ?: null,
|
||||
'keywords' => $this->decode((string) ($row->keywords ?? ''), $encoding) ?: null,
|
||||
'published_at' => $this->fromUnix($row->dateline ?? null),
|
||||
'views' => (int) ($row->views ?? 0),
|
||||
'comments_count' => (int) ($row->comments ?? 0),
|
||||
'stick' => (bool) ($row->stick ?? false),
|
||||
'visible' => (bool) ($row->visible ?? true),
|
||||
'close_comment' => (bool) ($row->closecomment ?? false),
|
||||
'read_password' => ($row->readpassword ?? '') !== '' ? (string) $row->readpassword : null,
|
||||
'legacy_attachments' => $legacyAttachments,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function importComments($source, string $prefix, string $encoding): void
|
||||
{
|
||||
$table = $prefix.'comments';
|
||||
if (! $this->sourceTableExists($source, $table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($source->table($table)->orderBy('commentid')->cursor() as $row) {
|
||||
$this->report['comments']++;
|
||||
|
||||
// Avoid plugin hooks (e.g. AI moderation) rewriting imported statuses / flooding queues.
|
||||
Comment::withoutEvents(function () use ($row, $encoding): void {
|
||||
$this->upsertWithId(Comment::class, (int) $row->commentid, [
|
||||
'article_id' => (int) $row->articleid,
|
||||
'author' => $this->decode((string) $row->author, $encoding),
|
||||
'url' => $this->decode((string) ($row->url ?? ''), $encoding) ?: null,
|
||||
'content' => $this->decode((string) $row->content, $encoding),
|
||||
'ip' => $row->ipaddress ?? null,
|
||||
'moderation_status' => ((int) ($row->visible ?? 0)) === 1
|
||||
? Comment::STATUS_APPROVED
|
||||
: Comment::STATUS_PENDING,
|
||||
'published_at' => $this->fromUnix($row->dateline ?? null),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected function importTags($source, string $prefix, string $encoding): void
|
||||
{
|
||||
$table = $prefix.'tags';
|
||||
if (! $this->sourceTableExists($source, $table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($source->table($table)->orderBy('tagid')->cursor() as $row) {
|
||||
$this->report['tags']++;
|
||||
|
||||
$tag = $this->upsertWithId(Tag::class, (int) $row->tagid, [
|
||||
'name' => $this->decode((string) $row->tag, $encoding),
|
||||
'use_count' => (int) ($row->usenum ?? 0),
|
||||
]);
|
||||
|
||||
$ids = preg_split('/\s*,\s*/', (string) ($row->aids ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
$articleIds = collect($ids)->map(fn ($id) => (int) $id)->filter()->unique()->values()->all();
|
||||
$tag->articles()->sync($articleIds);
|
||||
}
|
||||
}
|
||||
|
||||
protected function importLinks($source, string $prefix, string $encoding): void
|
||||
{
|
||||
$table = $prefix.'links';
|
||||
if (! $this->sourceTableExists($source, $table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($source->table($table)->orderBy('linkid')->cursor() as $row) {
|
||||
$this->report['links']++;
|
||||
|
||||
$this->upsertWithId(Link::class, (int) $row->linkid, [
|
||||
'name' => $this->decode((string) $row->name, $encoding),
|
||||
'url' => (string) $row->url,
|
||||
'note' => $this->decode((string) ($row->note ?? ''), $encoding) ?: null,
|
||||
'display_order' => (int) ($row->displayorder ?? 0),
|
||||
'visible' => (bool) ($row->visible ?? true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function importStylevars($source, string $prefix, string $encoding): void
|
||||
{
|
||||
$table = $prefix.'stylevars';
|
||||
if (! $this->sourceTableExists($source, $table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($source->table($table)->orderBy('stylevarid')->cursor() as $row) {
|
||||
$this->report['stylevars']++;
|
||||
|
||||
$this->upsertWithId(Stylevar::class, (int) $row->stylevarid, [
|
||||
'title' => $this->decode((string) $row->title, $encoding),
|
||||
'value' => $this->decode((string) ($row->value ?? ''), $encoding),
|
||||
'visible' => (bool) ($row->visible ?? true),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function importAttachments($source, string $prefix, ?string $attachmentsRoot, string $disk): void
|
||||
{
|
||||
$table = $prefix.'attachments';
|
||||
if (! $this->sourceTableExists($source, $table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$retryFailed = (bool) $this->option('retry-failed');
|
||||
|
||||
foreach ($source->table($table)->orderBy('attachmentid')->cursor() as $row) {
|
||||
$id = (int) $row->attachmentid;
|
||||
$legacy = ltrim(str_replace('\\', '/', (string) $row->filepath), '/');
|
||||
$existing = Attachment::query()->find($id);
|
||||
|
||||
if ($existing && $existing->synced_at && ! $retryFailed) {
|
||||
$this->report['attachments_ok']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$local = $attachmentsRoot
|
||||
? rtrim($attachmentsRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $legacy)
|
||||
: null;
|
||||
|
||||
$articleId = (int) ($row->articleid ?? 0) ?: null;
|
||||
$filename = (string) ($row->filename ?: basename($legacy));
|
||||
$key = sprintf(
|
||||
'attachments/%s/%d/%s.%s',
|
||||
$articleId ?: 'orphan',
|
||||
$id,
|
||||
substr(hash('sha256', $legacy.$id), 0, 16),
|
||||
pathinfo($filename, PATHINFO_EXTENSION) ?: 'bin'
|
||||
);
|
||||
|
||||
$payload = [
|
||||
'article_id' => $articleId,
|
||||
'disk' => $disk,
|
||||
'path' => $key,
|
||||
'thumb_path' => null,
|
||||
'filename' => $filename,
|
||||
'mime' => $row->filetype ?: null,
|
||||
'size' => (int) ($row->filesize ?? 0),
|
||||
'checksum' => null,
|
||||
'visibility' => Attachment::VISIBILITY_PUBLIC,
|
||||
'legacy_filepath' => $legacy,
|
||||
'downloads' => (int) ($row->downloads ?? 0),
|
||||
'synced_at' => null,
|
||||
];
|
||||
|
||||
if (! $local || ! is_file($local)) {
|
||||
$this->report['attachments_missing']++;
|
||||
$this->upsertWithId(Attachment::class, $id, $payload);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload['checksum'] = hash_file('sha256', $local) ?: null;
|
||||
$payload['size'] = filesize($local) ?: $payload['size'];
|
||||
$payload['mime'] = mime_content_type($local) ?: $payload['mime'];
|
||||
|
||||
$stream = fopen($local, 'r');
|
||||
Storage::disk($disk)->put($key, $stream, ['visibility' => 'public']);
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
|
||||
$thumbLegacy = ltrim(str_replace('\\', '/', (string) ($row->thumb_filepath ?? '')), '/');
|
||||
if ($attachmentsRoot && $thumbLegacy !== '') {
|
||||
$thumbLocal = rtrim($attachmentsRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $thumbLegacy);
|
||||
if (is_file($thumbLocal)) {
|
||||
$thumbKey = preg_replace('/(\.[^.]+)?$/', '_thumb$1', $key) ?: $key.'_thumb';
|
||||
$thumbStream = fopen($thumbLocal, 'r');
|
||||
Storage::disk($disk)->put($thumbKey, $thumbStream, ['visibility' => 'public']);
|
||||
if (is_resource($thumbStream)) {
|
||||
fclose($thumbStream);
|
||||
}
|
||||
$payload['thumb_path'] = $thumbKey;
|
||||
}
|
||||
}
|
||||
|
||||
$payload['synced_at'] = now();
|
||||
$this->upsertWithId(Attachment::class, $id, $payload);
|
||||
$this->report['attachments_ok']++;
|
||||
} catch (Throwable $e) {
|
||||
$this->report['attachments_failed']++;
|
||||
$this->warn("Attachment #{$id} failed: ".$e->getMessage());
|
||||
$this->upsertWithId(Attachment::class, $id, $payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<Model> $modelClass
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
protected function upsertWithId(string $modelClass, int $id, array $values): Model
|
||||
{
|
||||
/** @var Model $model */
|
||||
$model = $modelClass::query()->find($id) ?? new $modelClass;
|
||||
$model->forceFill(['id' => $id] + $values)->save();
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
protected function countTable($source, string $table, string $key): void
|
||||
{
|
||||
if ($this->sourceTableExists($source, $table)) {
|
||||
$this->report[$key] = (int) $source->table($table)->count();
|
||||
}
|
||||
}
|
||||
|
||||
protected function sourceTableExists($source, string $table): bool
|
||||
{
|
||||
try {
|
||||
return Schema::connection($source->getName())->hasTable($table);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function decode(string $value, string $encoding): string
|
||||
{
|
||||
if ($value === '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$encoding = strtolower($encoding);
|
||||
if ($encoding === 'utf8' || $encoding === 'utf-8') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($encoding === 'gbk' || $encoding === 'gb2312') {
|
||||
return mb_convert_encoding($value, 'UTF-8', 'GBK');
|
||||
}
|
||||
|
||||
if (! mb_check_encoding($value, 'UTF-8')) {
|
||||
$converted = @mb_convert_encoding($value, 'UTF-8', 'GBK');
|
||||
|
||||
return $converted !== false ? $converted : $value;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
protected function fromUnix(mixed $timestamp): ?\Illuminate\Support\Carbon
|
||||
{
|
||||
$ts = (int) $timestamp;
|
||||
|
||||
return $ts > 0 ? \Illuminate\Support\Carbon::createFromTimestamp($ts) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
class ThemesPublishCommand extends Command
|
||||
{
|
||||
protected $signature = 'themes:publish {theme? : Theme slug to publish; omit for all}';
|
||||
|
||||
protected $description = 'Copy theme assets into public/themes for correct static MIME serving';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$themePath = config('larablog.theme_path', base_path('themes'));
|
||||
$publicBase = public_path('themes');
|
||||
File::ensureDirectoryExists($publicBase);
|
||||
|
||||
$only = $this->argument('theme');
|
||||
$directories = collect(File::directories($themePath))
|
||||
->when($only, fn ($c) => $c->filter(fn ($dir) => basename($dir) === $only));
|
||||
|
||||
if ($directories->isEmpty()) {
|
||||
$this->error($only ? "Theme [{$only}] not found." : 'No themes found.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
foreach ($directories as $directory) {
|
||||
$slug = basename($directory);
|
||||
$assets = $directory.'/assets';
|
||||
if (! is_dir($assets)) {
|
||||
$this->warn("Skip {$slug}: no assets/");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $publicBase.'/'.$slug;
|
||||
File::deleteDirectory($target);
|
||||
File::copyDirectory($assets, $target);
|
||||
$this->info("Published theme assets: {$slug} → public/themes/{$slug}");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Workerman\Timer;
|
||||
use Workerman\Worker;
|
||||
|
||||
/**
|
||||
* Long-lived Workerman process for AI queues (content optimize + comment moderation).
|
||||
*/
|
||||
class WorkermanAiCommand extends Command
|
||||
{
|
||||
protected $signature = 'workerman:ai {--count=1 : Worker processes}';
|
||||
|
||||
protected $description = 'Start Workerman workers that process ai-content and ai-moderation queues';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Starting Workerman AI runtime (queues: ai-content, ai-moderation)...');
|
||||
|
||||
Worker::$pidFile = storage_path('logs/workerman-ai.pid');
|
||||
Worker::$logFile = storage_path('logs/workerman-ai.log');
|
||||
|
||||
$worker = new Worker();
|
||||
$worker->count = max(1, (int) $this->option('count'));
|
||||
$worker->name = 'larablog-ai';
|
||||
|
||||
$worker->onWorkerStart = function () {
|
||||
Timer::add(1, function () {
|
||||
Artisan::call('queue:work', [
|
||||
'--queue' => 'ai-content,ai-moderation',
|
||||
'--stop-when-empty' => true,
|
||||
'--max-time' => 50,
|
||||
'--sleep' => 1,
|
||||
'--tries' => 3,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
Worker::runAll();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user