Migration complete

This commit is contained in:
2026-06-23 19:24:38 +02:00
parent 279160c1cb
commit 64b26ef059
126 changed files with 8121 additions and 221 deletions

View File

@@ -5,32 +5,60 @@ namespace App\Auth;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Http\Request;
/**
* Xenforo authentification bridge.
*/
class XenForoGuard implements Guard
{
/**
* Authenticated user.
* @var XenForoUser|null
*/
private ?XenForoUser $user = null;
public function __construct(private readonly Request $request) {}
/**
* Check if user is logged in.
* @return bool
*/
public function check(): bool
{
return $this->user() !== null;
}
/**
* Check if user is a guest.
* @return bool
*/
public function guest(): bool
{
return ! $this->check();
}
/**
* Get user ID.
* @return mixed
*/
public function id(): mixed
{
return $this->user()?->getAuthIdentifier();
}
/**
* If user is defined.
* @return bool
*/
public function hasUser(): bool
{
return $this->user !== null;
}
/**
* Login user.
* @return XenForoUser|null
*/
public function user(): ?XenForoUser
{
if ($this->hasUser())
@@ -64,6 +92,13 @@ class XenForoGuard implements Guard
return $this->user = new XenForoUser($xfUser);
}
/**
* Unused.
*
* @param array $credentials
*
* @return bool
*/
public function validate(array $credentials = []): bool
{
return false;
@@ -74,6 +109,10 @@ class XenForoGuard implements Guard
$this->user = $user;
}
/**
* Unused.
* @return void
*/
public function logout(): void
{
redirect('/');

View File

@@ -10,11 +10,67 @@ use Filament\Panel;
use Illuminate\Contracts\Auth\Access\Authorizable;
use Illuminate\Contracts\Auth\Authenticatable;
/**
* Xenforo user custom model used for authentification.
*
* @property-read int $user_id
* @property-read string $username
* @property-read int $username_date
* @property-read int $username_date_visible
* @property-read string $email
* @property-read string $custom_title
* @property-read int $language_id
* @property-read int $style_id
* @property-read string $style_variation
* @property-read string $timezone
* @property-read int $visible
* @property-read int $activity_visible
* @property-read int $user_group_id
* @property-read array $secondary_group_ids
* @property-read int $display_style_group_id
* @property-read int $permission_combination_id
* @property-read int $message_count
* @property-read int $question_solution_count
* @property-read int $conversations_unread
* @property-read int $register_date
* @property-read int $last_activity
* @property-read int $last_summary_email_date
* @property-read int $trophy_points
* @property-read int $alerts_unviewed
* @property-read int $alerts_unread
* @property-read int $avatar_date
* @property-read int $avatar_width
* @property-read int $avatar_height
* @property-read int $avatar_highdpi
* @property-read int $avatar_optimized
* @property-read string $gravatar
* @property-read string $user_state
* @property-read string $security_lock
* @property-read int $is_moderator
* @property-read int $is_admin
* @property-read int $is_banned
* @property-read int $reaction_score
* @property-read int $vote_score
* @property-read int $warning_points
* @property-read int $is_staff
* @property-read string $secret_key
* @property-read int $privacy_policy_accepted
* @property-read int $terms_accepted
*
* Custom properties.
*
* @property-read int $rhpz_entry_count
*/
class XenForoUser extends XenForoData implements Authenticatable, Authorizable, FilamentUser, HasName {
use \Illuminate\Foundation\Auth\Access\Authorizable;
/**
* Permissions identifier array.
* @var array|null
*/
public ?array $permissions = null;
public function getAuthIdentifierName(): string
{
return 'user_id';
@@ -51,9 +107,9 @@ class XenForoUser extends XenForoData implements Authenticatable, Authorizable,
}
/**
* Get XenForo avatar if it exist.
* Get XenForo avatar if it exists.
*
* @param string $xfSize
* @param string $xfSize s/m/...
*
* @return string|null
*/
@@ -70,6 +126,14 @@ class XenForoUser extends XenForoData implements Authenticatable, Authorizable,
return null;
}
/**
* Custom can function. Check XF user permissions.
*
* @param string $permissionGroup
* @param string $permissionName
*
* @return bool
*/
public function _can(string $permissionGroup, string $permissionName): bool
{
if( !$this->permissions ){
@@ -105,4 +169,9 @@ class XenForoUser extends XenForoData implements Authenticatable, Authorizable,
{
return 'user_id';
}
public function validState(): bool
{
return $this->user_state === 'valid';
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('fix:encoded-slugs')]
class FixEncodedSlugs extends Command
{
private const array TABLES = [
'entries', 'games', 'platforms', 'genres', 'languages', 'modifications',
'levels', 'statuses', 'authors', 'categories', 'systems',
];
public function handle()
{
foreach (self::TABLES as $table) {
$rows = DB::table($table)->where('slug', 'like', '%\\%%')->get(['id', 'slug']);
$fixed = 0;
foreach ($rows as $row) {
$decoded = rawurldecode($row->slug);
if ($decoded === $row->slug) continue;
try {
DB::table($table)->where('id', $row->id)->update(['slug' => $decoded]);
$fixed++;
} catch (\Throwable $e) {
$this->warn("{$table}#{$row->id} : collision '{$decoded}', ignored ({$e->getMessage()}).");
}
}
if ($fixed > 0) {
$this->info("{$table}: {$fixed} fixed slugs.");
}
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Console\Commands;
use App\Helpers\MigrationHelpers;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use League\HTMLToMarkdown\HtmlConverter;
#[Signature('fix:entries-description')]
#[Description('Reconvert HTML descriptions to Markdown')]
class FixEntriesDescription extends Command
{
public function handle()
{
$converter = new HtmlConverter(['hard_break' => true]);
$rows = DB::table('migrations_logs')
->where('source_system', 'wp')
->where('source_table', 'wp_posts')
->where('target_table', 'entries')
->get(['source_id', 'target_id']);
$this->info("{$rows->count()} entries touched.");
$this->withProgressBar($rows, function ($row) use ($converter) {
$rawHtml = DB::connection('old_wp')->table('posts')
->where('ID', $row->source_id)
->value('post_content');
if ($rawHtml === null) return;
$markdown = trim($rawHtml) === '' ? $rawHtml : $converter->convert(MigrationHelpers::wpAutoP($rawHtml));
DB::table('entries')->where('id', $row->target_id)->update([
'description' => $markdown,
'updated_at' => now(),
]);
});
$this->newLine();
$this->info('Process finished.');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
#[Signature('migrate:categories:configure')]
#[Description('Configure categories table.')]
class MigrateCategoriesConfigure extends Command
{
public function handle()
{
$taxonomyMap = [];
$taxonomy = "a";
$section = "";
while( $taxonomy !== "" ){
$taxonomy = "";
$section = "";
$taxonomy = $this->ask("Write WP taxonomy name. Write nothing if you want to save changes.", "" );
if( $taxonomy == "" )
break;
$section = $this->ask("Write entry section equivalent.");
$taxonomyMap[$taxonomy] = $section;
}
DB::table('migration_settings')
->updateOrInsert([
'key' => 'wp_categories_to_entry_sections'
],[
'value' => json_encode($taxonomyMap), 'updated_at' => now()
]);
$this->info('WP Categories to entry sections has been configured.');
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('migrate:categories:execute')]
#[Description('Migrate categories, make sure you have configured the migration before that.')]
class MigrateCategoriesExecute extends Command
{
private const string TABLE = 'categories';
public function handle()
{
$taxMap = json_decode(DB::table('migration_settings')->where('key', 'wp_categories_to_entry_sections')->value('value'), true);
if( !$taxMap ){
$this->error("No WP taxonomies need to be transferred.");
return self::FAILURE;
}
if( $this->ask("Are you sure you want launch that migration? Write ok if you want to launch it.", "") !== 'ok' ){
return self::SUCCESS;
}
$table = self::TABLE;
foreach ( $taxMap as $wpTax => $restrictedTo )
{
$this->info("Migrate: {$wpTax} restricted to {$table}");
$terms = DB::connection('old_wp')
->table('term_taxonomy')
->join('terms', 'term_taxonomy.term_id', '=', 'terms.term_id')
->where('term_taxonomy.taxonomy', $wpTax)
->select('term_taxonomy.term_taxonomy_id', 'terms.name', 'terms.slug')
->get();
$this->withProgressBar($terms, function ($term) use ($table, $restrictedTo) {
$exists = DB::table('migrations_logs')
->where('source_system', 'wp')
->where('source_table', 'wp_term_taxonomy')
->where('source_id', $term->term_taxonomy_id )
->exists();
if( $exists )
return;
$existing = DB::table( $table )->where('slug', $term->slug)->first();
if( $existing === null) {
$Id = DB::table($table)
->insertGetId([
'name' => $term->name, 'slug' => $term->slug,
'restricted_to' => json_encode([$restrictedTo]),
'created_at' => now(), 'updated_at' => now()
]);
} else {
$Id = $existing->id;
$restrictedToField = json_decode($existing->restricted_to, true) ?? [];
if( !in_array( $restrictedTo, $restrictedToField, true ) ){
$restrictedToField[] = $restrictedTo;
DB::table($table)
->where('id', $Id)
->update(['restricted_to' => json_encode($restrictedToField), 'updated_at' => now()]);
}
}
DB::table('migrations_logs')->insert([
'source_system' => 'wp',
'source_table' => 'wp_term_taxonomy',
'source_id' => $term->term_taxonomy_id,
'target_table' => $table,
'target_id' => $Id,
'status' => 'done',
'migrated_at' => now(),
'created_at' => now(),
'updated_at' => now()
]);
});
$this->newLine();
}
$this->info("Migration done");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
#[Signature('migrate:entries:comments {--import-log-table=} {--limit=}')]
class MigrateEntriesComments extends Command
{
public function handle()
{
$logTable = $this->option('import-log-table');
if( !$logTable ){
$this->error( 'XenForo import log table required' );
return self::FAILURE;
}
$this->commentsThread( 'entries', 'wp_posts', $logTable );
$this->commentsThread( 'news', 'wp_posts__news', $logTable );
$this->info( "Done" );
return self::SUCCESS;
}
private function commentsThread( string $table, string $sourceTable, string $logTable ): void
{
$query = DB::table('migrations_logs')
->where('source_system', 'wp' )
->where('source_table', $sourceTable )
->where('target_table', $table );
if( $limit = $this->option('limit') ){
$query->limit((int)$limit);
}
$rows = $query->get(['source_id', 'target_id']);
$this->info( "{$rows->count()} need migration logs" );
$stats = [ 'updated' => 0, 'no_meta' => 0, 'no_new_thread' => 0 ];
$this->withProgressBar( $rows, function ( $row ) use( $table, $logTable, &$stats ) {
$oldThreadId = DB::connection('old_wp')
->table('postmeta')
->where('post_id', $row->source_id )
->where('meta_key', 'xf_thread_id')
->value('meta_value');
if( !$oldThreadId ){
$stats['no_meta']++;
return;
}
$newThreadId = DB::connection('xenforo')
->withoutTablePrefix( function( Connection $db ) use( $logTable, $oldThreadId ){
return $db->table( $logTable )
->where('content_type', 'thread')
->where('old_id', (string) $oldThreadId )
->value('new_id');
});
if( !$newThreadId ){
$stats['no_new_thread']++;
return;
}
DB::table( $table )->where('id', $row->target_id )->update([
'comments_thread_id' => (int) $newThreadId
]);
$stats['updated']++;
});
$this->newLine();
$this->info( "Updated: {$stats['updated']}, No new thread: {$stats['no_new_thread']}, No meta: {$stats['no_meta']}" );
}
}

View File

@@ -0,0 +1,295 @@
<?php
namespace App\Console\Commands;
use App\Helpers\MigrationHelpers;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('migrate:entries:execute {--limit=}')]
#[Description('Migrate WP entries')]
class MigrateEntriesExecute extends Command
{
private const array WP_CPTS = ['translations', 'romhacks', 'homebrew', 'utilities', 'documents' ];
private const array STATE_MAP = [
'draft' => 'draft',
'pending' => 'pending',
'publish' => 'published',
'private' => 'hidden',
'locked' => 'locked',
];
private const array ACF_FIELDS = [
'entry_title', 'version_number', 'release_date', 'release_site',
'youtube_video', 'staff', 'hashes',
];
private const array MULTI_TAXONOMIES = [
'language' => 'languages',
'modifications' => 'modifications',
'author-name' => 'authors',
'document-category' => 'categories',
'utility-category' => 'categories',
'utility-os' => 'systems',
];
private array $stats = [];
private function getSingle( ?int $term_id, string $targetTable ): ?int
{
if (!$term_id) {
return null;
}
return DB::table('migrations_logs')
->where('source_system', 'wp')
->where('target_table', $targetTable )
->where('source_id', $term_id)
->value('target_id');
}
private function uniqueSlug(string $baseSlug, string $table, ?int $ignoreId = null): string
{
$slug = $baseSlug;
$i = 1;
while (
DB::table($table)->where('slug', $slug)
->when($ignoreId, fn ($q) => $q->where('id', '!=', $ignoreId))
->exists() && $i < 100
) {
$slug = $baseSlug . '-' . $i++;
}
if ($i >= 100) {
$slug = (string) \Str::uuid();
}
return $slug;
}
private function parseStaffCredits(?string $raw): array
{
if (!$raw || trim($raw) === '') {
return [];
}
$credits = [];
foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) {
$line = trim($line);
if ($line === '') continue;
if (preg_match('/^(.+?):\s*(.*)$/', $line, $m)) {
$credits[] = ['name' => trim($m[1]), 'description' => trim($m[2])];
} elseif (!empty($credits)) {
$last = array_key_last($credits);
$credits[$last]['description'] = trim($credits[$last]['description'] . ' ' . $line);
} else {
$credits[] = ['name' => $line, 'description' => ''];
}
}
return $credits;
}
private function parseHashes(?string $raw): array
{
if (!$raw || trim($raw) === '') return [];
$results = [];
foreach (preg_split('/\n\s*\n/', trim($raw)) as $block) {
$fields = [];
foreach (preg_split('/\r\n|\r|\n/', trim($block)) as $line) {
$line = trim($line);
if ($line === '' || !str_contains($line, ':')) continue;
[$key, $value] = array_map('trim', explode(':', $line, 2));
$fields[strtolower(preg_replace('/[^a-z0-9]/i', '', $key))] = $value;
}
if (empty($fields)) continue;
$results[] = [
'filename' => $fields['filename'] ?? '',
'hash_crc32' => $fields['crc32'] ?? '',
'hash_sha1' => $fields['sha1'] ?? '',
];
}
return $results;
}
private function attachMany(int $entryId, string $pivotTable, string $foreignKey, array $ttids, string $targetTable): void
{
if (empty($ttids)) return;
$ids = DB::table('migrations_logs')
->where('source_system', 'wp')->where('target_table', $targetTable)
->whereIn('source_id', $ttids)->pluck('target_id');
foreach ($ids as $id) {
DB::table($pivotTable)->insertOrIgnore(['entry_id' => $entryId, $foreignKey => $id]);
}
}
/**
* @param \StdClass $post
* @param string $cpt
*
* @return void
*/
private function migratePost( $post, string $cpt ): void
{
$exists = DB::table('migrations_logs')
->where('source_system', 'wp')
->where('source_table', 'wp_posts')
->where('source_id', $post->ID)
->exists();
if ($exists)
return;
$meta = DB::connection('old_wp')
->table('postmeta')
->where('post_id', $post->ID)
->whereIn('meta_key', self::ACF_FIELDS)
->pluck('meta_value', 'meta_key');
$terms = DB::connection('old_wp')
->table('term_relationships as tr')
->join('term_taxonomy as tt', 'tr.term_taxonomy_id', '=', 'tt.term_taxonomy_id')
->where('tr.object_id', $post->ID)
->whereIn('tt.taxonomy', array_merge(['game', 'platform', 'hack-status', 'experience-level'], array_keys(self::MULTI_TAXONOMIES)) )
->select('tt.taxonomy', 'tt.term_taxonomy_id')
->get();
$byTax = [];
foreach ($terms as $term) {
$byTax[$term->taxonomy][] = $term->term_taxonomy_id;
}
$gameId = null;
if( !empty( $byTax['game'][0] ) && !empty( $byTax['platform'][0] ) )
{
$gameId = DB::table('migration_game_plan')
->where('wp_game_id', $byTax['game'][0])
->where('wp_platform_id', $byTax['platform'][0])
->value('game_id');
if( !$gameId )
$this->stats['missing_game_plan']++;
}
$statusId = $this->getSingle( $byTax['hack-status'][0] ?? null, 'statuses' );
$levelId = $this->getSingle( $byTax['experience-level'][0] ?? null, 'levels' );
$userId = DB::table('migration_user_plan')
->where('wp_user_id', $post->post_author )
->value('user_id');
if( !$userId ) {
$this->stats['missing_author']++;
return;
}
$title = $meta['entry_title'] ?? null;
if( $cpt === 'translations' && !$title && $gameId )
{
$title = DB::table('games')->where('id', $gameId)->value('name');
}
$slug = $this->uniqueSlug( rawurldecode($post->post_name), 'entries' );
$entryId = DB::table('entries')->insertGetId([
'type' => $cpt,
'title' => $title,
'complete_title' => $post->post_title ?? null,
'slug' => $slug,
'description' => MigrationHelpers::htmlToMarkdown($post->post_content),
'state' => self::STATE_MAP[$post->post_status],
'game_id' => $gameId,
'platform_id' => null,
'status_id' => $statusId,
'level_id' => $levelId,
'version' => $meta['version_number'] ?? null,
'release_date' => $meta['release_date'] ?? null,
'staff_credits' => json_encode( $this->parseStaffCredits($meta['staff'] ?? null)),
'relevant_link' => $meta['release_site'] ?? null,
'youtube_link' => $meta['youtube_video'] ?? null,
'user_id' => $userId,
'created_at' => $post->post_date,
'updated_at' => $post->post_modified,
]);
$this->attachMany($entryId, 'entry_authors', 'author_id', $byTax['author-name'] ?? [], 'authors');
$this->attachMany($entryId, 'entry_languages', 'language_id', $byTax['language'] ?? [], 'languages');
if( $cpt === 'romhacks')
$this->attachMany($entryId, 'entry_modifications', 'modification_id', $byTax['modifications'] ?? [], 'modifications');
if ($cpt === 'utilities') {
$this->attachMany($entryId, 'entry_categories', 'category_id', $byTax['utility-category'] ?? [], 'categories');
$this->attachMany($entryId, 'entry_systems', 'system_id', $byTax['utility-os'] ?? [], 'systems');
}
if ($cpt === 'documents') {
$this->attachMany($entryId, 'entry_categories', 'category_id', $byTax['document-category'] ?? [], 'categories');
}
if( $cpt === 'translations' || $cpt === 'romhacks' ){
foreach ( $this->parseHashes( $meta['hashes'] ?? null ) as $hash ) {
DB::table('entry_hashes')->insert([
'entry_id' => $entryId,
'filename' => $hash['filename'],
'hash_crc32' => $hash['hash_crc32'],
'hash_sha1' => $hash['hash_sha1'],
'verified' => 'TBD',
'created_at' => now(),
'updated_at' => now(),
]);
}
}
DB::table('migrations_logs')->insert([
'source_system' => 'wp',
'source_table' => 'wp_posts',
'source_id' => $post->ID,
'target_table' => 'entries',
'target_id' => $entryId,
'status' => 'done',
'migrated_at' => now(),
'updated_at' => now(),
'created_at' => now(),
]);
$this->stats['created']++;
}
private function migrateCpt( string $cpt ): void
{
$this->info( "Current migration of : $cpt ");
$this->stats = [ 'missing_author' => 0, 'missing_game_plan' => 0, 'created' => 0 ];
$query = DB::connection('old_wp')
->table('posts')
->where('post_type', $cpt)
->whereIn('post_status', array_keys(self::STATE_MAP))
;
if( $limit = $this->option('limit') ) {
$query->limit((int) $limit);
}
$posts = $query->select('ID', 'post_title', 'post_name', 'post_content', 'post_status', 'post_author', 'post_date', 'post_modified')
->get();
$this->withProgressBar($posts, fn( $post ) => $this->migratePost( $post, $cpt ) );
$this->newLine();
$this->info( "Created {$this->stats['created']} entries'. No authors {$this->stats['missing_author']}. Missing game {$this->stats['missing_game_plan']} entries" );
}
public function handle()
{
foreach ( self::WP_CPTS as $cpt ) {
$this->migrateCpt( $cpt );
}
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
#[Signature('migrate:entries:images {--wp-uploads-path=} {--limit=}')]
#[Description("Migrate WP Main images and galeries for entries.")]
class MigrateEntriesImages extends Command
{
private const string GALLERYABLE_TYPE = 'App\\Models\\Entry';
private function copyAttachment( int $attachmentId, string $wpUploadsPath, string $destinationPath ): ?string
{
$relativePath = DB::connection('old_wp')
->table('postmeta')
->where('post_id', $attachmentId)
->where('meta_key', '_wp_attached_file')
->value('meta_value');
if( !$relativePath )
return null;
$sourcePath = rtrim($wpUploadsPath, '/') . '/' . $relativePath;
if( !is_file($sourcePath) )
return null;
$extension = pathinfo($sourcePath, PATHINFO_EXTENSION);
$filename = Str::random(40) . ($extension ? '.' . $extension : '');
$destinationRelative = $destinationPath . '/' . $filename;
$stream = fopen($sourcePath, 'r');
Storage::disk('public')->put($destinationRelative, $stream);
if( is_resource($stream) )
fclose($stream);
return $destinationRelative;
}
private function processEntry( int $wpPostId, int $entryId, string $wpUploadsPath, array &$stats ): void
{
$exists = DB::table('migrations_logs')
->where('source_system', 'wp')
->where('source_table', 'wp_posts__attachments')
->where('source_id', $wpPostId)
->exists();
if( $exists )
return;
$thumbnailId = DB::connection('old_wp')
->table('postmeta')
->where('post_id', $wpPostId)
->where('meta_key', '_thumbnail_id')
->value('meta_value');
if( $thumbnailId ){
$newPath = $this->copyAttachment( (int) $thumbnailId, $wpUploadsPath, 'entries/main-images' );
if( $newPath ){
DB::table('entries')
->where('id', $entryId)
->update(['main_image' => $newPath]);
$stats['main_image']++;
} else {
$stats['missing_files']++;
}
}
$galleryRaw = DB::connection('old_wp')
->table('postmeta')
->where('post_id', $wpPostId)
->where('meta_key', 'my_gallery')
->value('meta_value');
$attachmentIds = $galleryRaw ? (@unserialize($galleryRaw) ?: []) : [];
foreach ( array_values( $attachmentIds ) as $order => $attachmentId ) {
$newPath = $this->copyAttachment( (int) $attachmentId, $wpUploadsPath, "entries/gallery-images/{$entryId}" );
if( !$newPath ){
$stats['missing_files']++;
continue;
}
DB::table('galleries')
->insert([
'galleryable_type' => self::GALLERYABLE_TYPE,
'galleryable_id' => $entryId,
'image' => $newPath,
'order' => $order,
'created_at' => now(),
'updated_at' => now(),
]);
$stats['gallery_images']++;
}
DB::table('migrations_logs')
->insert([
'source_system' => 'wp',
'source_table' => 'wp_posts__attachments',
'source_id' => $wpPostId,
'target_table' => 'entries',
'target_id' => $entryId,
'status' => 'done',
'migrated_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
public function handle()
{
$wpUploadsPath = $this->option('wp-uploads-path');
if( !$wpUploadsPath || !is_dir($wpUploadsPath) ){
$this->error('Missing WP Uploads Path');
return self::FAILURE;
}
$query = DB::table('migrations_logs')
->where('source_system', 'wp')
->where('source_table', 'wp_posts')
->where('target_table', 'entries')
;
if( $limit = $this->option('limit') ){
$query->limit((int)$limit);
}
$rows = $query->get(['source_id', 'target_id']);
$this->info("{$rows->count()} entries need to be migrated");
$stats = ['main_image' => 0, 'gallery_images' => 0, 'missing_files' => 0 ];
$this->withProgressBar($rows, function($row) use($wpUploadsPath, &$stats) {
$this->processEntry( $row->source_id, $row->target_id, $wpUploadsPath, $stats );
});
$this->newLine();
$this->info("Migrated attachments. Main images: {$stats['main_image']}, Galleries: {$stats['gallery_images']}, Missing files: {$stats['missing_files']}");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace App\Console\Commands;
use App\Models\Platform;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('migrate:games:execute')]
#[Description("Migrate all games from WP posts.")]
class MigrateGamesExecute extends Command
{
private const array WP_CPTS = [ 'translations', 'romhacks', 'homebrew', 'utilities', 'documents', 'lua-scripts' ];
private const array RELATED_TAXS = ['game', 'platform', 'genre'];
private const int DEFAULT_GENRE_ID = 1;
private const int NO_GENRE_SENTINEL = 0;
public function handle()
{
$rows = DB::connection('old_wp')
->table('term_relationships as tr')
->join('term_taxonomy as tt', 'tr.term_taxonomy_id', '=', 'tt.term_taxonomy_id')
->join('posts as p', 'tr.object_id', '=', 'p.ID')
->whereIn('tt.taxonomy', self::RELATED_TAXS )
->whereIn('p.post_type', self::WP_CPTS )
->where('p.post_status', '!=', 'trash')
->select('p.ID as post_id', 'tt.taxonomy', 'tt.term_taxonomy_id' )
->get();
$byPost = [];
$ignored = 0;
foreach( $rows as $row ){
if( isset( $byPost[ $row->post_id ][$row->taxonomy] ) ){
$ignored++;
continue;
}
$byPost[ $row->post_id ][$row->taxonomy] = $row->term_taxonomy_id;
}
if( $ignored ){
$this->warn("$ignored posts with multiple taxs will be ignored.");
}
$structure = [];
$ignoredNoPlatform = 0;
foreach ( $byPost as $data )
{
if( empty( $data['game'] ) )
continue;
if( empty( $data['platform'] ) ){
$ignoredNoPlatform++;
continue;
}
$gameId = $data['game'];
$platformId = $data['platform'];
$genreId = $data['genre'] ?? self::NO_GENRE_SENTINEL;
$structure[$gameId][$platformId]['count'] = ( $structure[$gameId][$platformId]['count'] ?? 0 ) + 1;
$structure[$gameId][$platformId]['genres'][$genreId] = ( $structure[$gameId][$platformId]['genres'][$genreId] ?? 0 ) + 1;
}
if( $ignoredNoPlatform ){
$this->warn("$ignoredNoPlatform posts with no platforms will be ignored.");
}
$games = DB::connection('old_wp')
->table('term_taxonomy')
->join('terms', 'term_taxonomy.term_id', '=', 'terms.term_id')
->where('term_taxonomy.taxonomy', 'game' )
->select('term_taxonomy.term_taxonomy_id', 'terms.name', 'terms.slug' )
->get()
->keyBy('term_taxonomy_id');
$platformMap = DB::table('migrations_logs')
->where('source_system', 'wp' )
->where('target_table', 'platforms')
->pluck('target_id', 'source_id');
$genreMap = DB::table('migrations_logs')
->where('source_system', 'wp' )
->where('target_table', 'genres')
->pluck('target_id', 'source_id');
if( $this->ask("Are you sure you want launch that migration? Write ok if you want to launch it.", "") !== 'ok' ){
return self::SUCCESS;
}
$created = 0;
$genreConflicts = 0;
foreach( $structure as $gameId => $platforms )
{
$game = $games->get( $gameId );
if( !$game )
continue;
foreach( $platforms as $platformId => $info )
{
$alreadyExists = DB::table('migration_game_plan')
->where('wp_game_id', $gameId )
->where('wp_platform_id', $platformId )
->exists();
if( $alreadyExists )
continue;
$newPlatformId = $platformMap[$platformId] ?? null;
if( !$newPlatformId ){
$this->warn("$gameId ignored because platform $platformId does not exist in Laravel.");
continue;
}
$genres = $info['genres'];
$newGenreId = null;
$topId = null;
$genreConflict = false;
arsort( $genres );
$topId = array_key_first( $genres );
$topCount = $genres[$topId];
$tied = count(array_filter(
$genres, fn( $c ) => $c === $topCount
));
if( $tied > 1 ){
$genreConflict = true;
$genreConflicts++;
}
$newGenreId = $topId === self::NO_GENRE_SENTINEL
? self::DEFAULT_GENRE_ID
: ($genreMap[$topId] ?? self::DEFAULT_GENRE_ID);
$gameSlug = $game->slug;
if (count($platforms) > 1) {
$platformSlug = DB::table('platforms')->where('id', $newPlatformId)->value('slug');
$gameSlug = $game->slug . '-' . $platformSlug;
}
$newGameId = DB::table('games')
->insertGetId([
'name' => $game->name,
'slug' => $gameSlug,
'platform_id' => $newPlatformId,
'genre_id' => $newGenreId,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('migration_game_plan')->insert([
'wp_game_id' => $gameId,
'wp_platform_id' => $platformId,
'game_id' => $newGameId,
'wp_genre_id' => $topId === self::NO_GENRE_SENTINEL ? null : $topId,
'post_count' => $info['count'],
'genre_conflict' => $genreConflict,
'created_at' => now(),
'updated_at' => now(),
]);
$created++;
}
}
$this->newLine();
$this->info("$created games created. $genreConflicts genre conflicts.");
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace App\Console\Commands;
use App\Helpers\MigrationHelpers;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
#[Signature('migrate:news:execute {--limit=}')]
#[Description("Migrate WP news to news table")]
class MigrateNewsExecute extends Command
{
private const array STATE_MAP = [
'draft' => 'draft',
'pending' => 'pending',
'publish' => 'published',
'private' => 'hidden',
'locked' => 'locked',
];
private array $stats = [];
private function uniqueSlug(string $baseSlug, string $table, ?int $ignoreId = null): string
{
$slug = $baseSlug;
$i = 1;
while (
DB::table($table)->where('slug', $slug)
->when($ignoreId, fn ($q) => $q->where('id', '!=', $ignoreId))
->exists() && $i < 100
) {
$slug = $baseSlug . '-' . $i++;
}
if ($i >= 100) {
$slug = (string) Str::uuid();
}
return $slug;
}
private function migrateNews($post): void
{
$exists = DB::table('migrations_logs')
->where('source_system', 'wp')
->where('source_table', 'wp_posts__news')
->where('source_id', $post->ID)
->exists();
if ($exists)
return;
$meta = DB::connection('old_wp')
->table('postmeta')
->where('post_id', $post->ID)
->whereIn('meta_key', ['release_site', 'romhacks_page', 'youtube_video' ])
->pluck('meta_value', 'meta_key');
$categoryTtId = DB::connection('old_wp')
->table('term_relationships as tr')
->join('term_taxonomy as tt', 'tr.term_taxonomy_id', '=', 'tt.term_taxonomy_id')
->where('tr.object_id', $post->ID)
->where('tt.taxonomy', 'news-category')
->value('tt.term_taxonomy_id');
$categoryId = null;
if( $categoryTtId ){
$categoryId = DB::table('migrations_logs')
->where('source_system', 'wp')
->where('target_table', 'categories' )
->where('source_id', $categoryTtId)
->value('target_id');
if( !$categoryId ){
$this->stats['missing_category']++;
}
}
$userId = DB::table('migration_user_plan')
->where('wp_user_id', $post->post_author )
->value('user_id');
if( !$userId ){
$this->stats['missing_author']++;
return;
}
$slug = $this->uniqueSlug(rawurldecode($post->post_name), 'news');
$description = trim($post->post_content) === '' ? '' : MigrationHelpers::htmlToMarkdown($post->post_content);
if( isset( $meta['romhacks_page'] ) && $meta['romhacks_page'] !== null && $meta['romhacks_page'] !== '' ){
$description .= "\n\nLink to: " . $meta['romhacks_page'];
}
$newsId = DB::table('news')
->insertGetId([
'title' => $post->post_title,
'slug' => $slug,
'category_id' => $categoryId,
'description' => $description,
'state' => self::STATE_MAP[$post->post_status],
'entry_id' => null,
'relevant_link' => $meta['release_site'] ?? null,
'youtube_link' => $meta['youtube_video'] ?? null,
'user_id' => $userId,
'created_at' => $post->post_date,
'updated_at' => $post->post_modified,
]);
DB::table('migrations_logs')->insert([
'source_system' => 'wp',
'source_table' => 'wp_posts__news',
'source_id' => $post->ID,
'target_table' => 'news',
'target_id' => $newsId,
'status' => 'done',
'migrated_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$this->stats['created']++;
}
public function handle()
{
$this->stats = [ 'created' => 0, 'missing_author' => 0, 'missing_category' => 0 ];
$query = DB::connection('old_wp')
->table('posts')
->where('post_type', 'news')
->whereIn('post_status', array_keys(self::STATE_MAP))
;
if( $limit = $this->option('limit') ) {
$query->limit((int)$limit);
}
$posts = $query->select('ID', 'post_title', 'post_name', 'post_content', 'post_author', 'post_status', 'post_date', 'post_modified')->get();
$this->info( "{$posts->count()} posts found" );
$this->withProgressBar($posts, fn($post) => $this->migrateNews($post) );
$this->newLine();
$this->info( "Created {$this->stats['created']} posts. Missing authors: {$this->stats['missing_author']}. Missing category: {$this->stats['missing_category']}" );
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
#[Signature('migrate:news:images {--wp-uploads-path=} {--limit=}')]
#[Description("Migrate WP galeries for news.")]
class MigrateNewsImages extends Command
{
private const string GALLERYABLE_TYPE = 'App\\Models\\News';
private function copyAttachment( int $attachmentId, string $wpUploadsPath, string $destinationPath ): ?string
{
$relativePath = DB::connection('old_wp')
->table('postmeta')
->where('post_id', $attachmentId)
->where('meta_key', '_wp_attached_file')
->value('meta_value');
if( !$relativePath )
return null;
$sourcePath = rtrim($wpUploadsPath, '/') . '/' . $relativePath;
if( !is_file($sourcePath) )
return null;
$extension = pathinfo($sourcePath, PATHINFO_EXTENSION);
$filename = Str::random(40) . ($extension ? '.' . $extension : '');
$destinationRelative = $destinationPath . '/' . $filename;
$stream = fopen($sourcePath, 'r');
Storage::disk('public')->put($destinationRelative, $stream);
if( is_resource($stream) )
fclose($stream);
return $destinationRelative;
}
private function processEntry( int $wpPostId, int $entryId, string $wpUploadsPath, array &$stats ): void
{
$exists = DB::table('migrations_logs')
->where('source_system', 'wp')
->where('source_table', 'wp_posts__attachments')
->where('source_id', $wpPostId)
->exists();
if( $exists )
return;
$galleryRaw = DB::connection('old_wp')
->table('postmeta')
->where('post_id', $wpPostId)
->where('meta_key', 'my_gallery')
->value('meta_value');
$attachmentIds = $galleryRaw ? (@unserialize($galleryRaw) ?: []) : [];
foreach ( array_values( $attachmentIds ) as $order => $attachmentId ) {
$newPath = $this->copyAttachment( (int) $attachmentId, $wpUploadsPath, "news/gallery-images/{$entryId}" );
if( !$newPath ){
$stats['missing_files']++;
continue;
}
DB::table('galleries')
->insert([
'galleryable_type' => self::GALLERYABLE_TYPE,
'galleryable_id' => $entryId,
'image' => $newPath,
'order' => $order,
'created_at' => now(),
'updated_at' => now(),
]);
$stats['gallery_images']++;
}
DB::table('migrations_logs')
->insert([
'source_system' => 'wp',
'source_table' => 'wp_posts__attachments',
'source_id' => $wpPostId,
'target_table' => 'news',
'target_id' => $entryId,
'status' => 'done',
'migrated_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
public function handle()
{
$wpUploadsPath = $this->option('wp-uploads-path');
if( !$wpUploadsPath || !is_dir($wpUploadsPath) ){
$this->error('Missing WP Uploads Path');
return self::FAILURE;
}
$query = DB::table('migrations_logs')
->where('source_system', 'wp')
->where('source_table', 'wp_posts__news')
->where('target_table', 'news')
;
if( $limit = $this->option('limit') ){
$query->limit((int)$limit);
}
$rows = $query->get(['source_id', 'target_id']);
$this->info("{$rows->count()} news need to be migrated");
$stats = ['gallery_images' => 0, 'missing_files' => 0 ];
$this->withProgressBar($rows, function($row) use($wpUploadsPath, &$stats) {
$this->processEntry( $row->source_id, $row->target_id, $wpUploadsPath, $stats );
});
$this->newLine();
$this->info("Migrated attachments Galleries: {$stats['gallery_images']}, Missing files: {$stats['missing_files']}");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Console\Commands;
use App\Helpers\MigrationHelpers;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('migrate:reviews:execute {--limit=}')]
class MigrateReviewsExecute extends Command
{
private function migrateReview( $post, array &$stats ): void
{
$exists = DB::table('migrations_logs')
->where('source_system', 'wp')
->where('source_table', 'wp_posts__reviews')
->where('source_id', $post->ID )
->exists();
if( $exists )
return;
$meta = DB::connection('old_wp')
->table('postmeta')
->where('post_id', $post->ID)
->whereIn('meta_key', ['reviews_post_link', 'review_rating'])
->pluck('meta_value', 'meta_key')
;
$linkedWpPostId = $meta['reviews_post_link'] ?? null;
if( !$linkedWpPostId ){
$stats['missing_entry']++;
return;
}
$entryId = DB::table('migrations_logs')
->where('source_system', 'wp')
->where('source_table', 'wp_posts')
->where('source_id', (int) $linkedWpPostId )
->where('target_table', 'entries')
->value('target_id');
if( !$entryId ){
$stats['missing_entry']++;
return;
}
$userId = DB::table('migration_user_plan')
->where('wp_user_id', $post->post_author )
->value('user_id');
if( !$userId ){
$stats['missing_author']++;
return;
}
$rating = $meta['review_rating'] ?? null;
if( $rating === null || $rating === '' ){
$stats['missing_rating']++;
return;
}
$description = trim($post->post_content) === '' ? '' : MigrationHelpers::htmlToMarkdown($post->post_content);
$reviewId = DB::table('entry_reviews')->insertGetId([
'entry_id' => $entryId,
'title' => $post->post_title,
'rating' => (int) $rating,
'description' => $description,
'user_id' => $userId,
'created_at' => $post->post_date,
'updated_at' => $post->post_modified,
]);
DB::table('migrations_logs')->insert([
'source_system' => 'wp',
'source_table' => 'wp_posts__reviews',
'source_id' => $post->ID,
'target_table' => 'entry_reviews',
'target_id' => $reviewId,
'status' => 'done',
'migrated_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$stats['created']++;
}
public function handle(): int
{
$query = DB::connection('old_wp')
->table('posts')
->where('post_type', 'reviews')
->where('post_status', 'publish')
;
if( $limit = $this->option('limit') ) {
$query->limit((int) $limit);
}
$posts = $query->select('ID', 'post_title', 'post_content', 'post_author', 'post_date', 'post_modified' )->get();
$this->info("{$posts->count()} reviews found.");
$stats = ['created' => 0, 'missing_entry' => 0, 'missing_author' => 0, 'missing_rating' => 0 ];
$this->withProgressBar($posts, function($post) use (&$stats){
$this->migrateReview($post, $stats);
});
$this->newLine();
$this->info("{$stats['created']} reviews created. {$stats['missing_entry']} missing entry. {$stats['missing_author']} missing author. {$stats['missing_rating']} missing rating.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
#[Signature('migrate:taxonomies:configure')]
#[Description('Configure taxonomies table.')]
class MigrateTaxonomiesConfigure extends Command
{
public function handle()
{
$taxonomyMap = [];
$taxonomy = "a";
$tableName = "";
while( $taxonomy !== "" ){
$taxonomy = "";
$tableName = "";
$taxonomy = $this->ask("Write WP taxonomy name. Write nothing if you want to save changes.", "" );
if( $taxonomy == "" )
break;
$tableName = $this->ask("Write equivalent Laravel table name.");
$taxonomyMap[$taxonomy] = $tableName;
}
DB::table('migration_settings')
->updateOrInsert([
'key' => 'wp_taxonomies_to_laravel_tables'
],[
'value' => json_encode($taxonomyMap), 'updated_at' => now()
]);
$this->info('WP Taxonomies to Laravel tables have been configured.');
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('migrate:taxonomies:execute')]
#[Description('Migrate taxonomies, make sure you have configured the migration before that.')]
class MigrateTaxonomiesExecute extends Command
{
public function handle()
{
$taxMap = json_decode(DB::table('migration_settings')->where('key', 'wp_taxonomies_to_laravel_tables')->value('value'), true);
if( !$taxMap ){
$this->error("No WP taxonomies need to be transferred.");
return self::FAILURE;
}
if( $this->ask("Are you sure you want launch that migration? Write ok if you want to launch it.", "") !== 'ok' ){
return self::SUCCESS;
}
foreach ( $taxMap as $wpTax => $table )
{
$this->info("Migrate: {$wpTax} => {$table}");
$terms = DB::connection('old_wp')
->table('term_taxonomy')
->join('terms', 'term_taxonomy.term_id', '=', 'terms.term_id')
->where('term_taxonomy.taxonomy', $wpTax)
->select('term_taxonomy.term_taxonomy_id', 'terms.name', 'terms.slug')
->get();
$this->withProgressBar($terms, function ($term) use ($table) {
$exists = DB::table('migrations_logs')
->where('source_system', 'wp')
->where('source_table', 'wp_term_taxonomy')
->where('source_id', $term->term_taxonomy_id )
->exists();
if( $exists )
return;
$Id = DB::table( $table )->where('slug', $term->slug)->value('id');
if( $Id === null) {
$Id = DB::table($table)
->insertGetId([
'name' => $term->name, 'slug' => $term->slug,
'created_at' => now(), 'updated_at' => now()
]);
}
DB::table('migrations_logs')->insert([
'source_system' => 'wp',
'source_table' => 'wp_term_taxonomy',
'source_id' => $term->term_taxonomy_id,
'target_table' => $table,
'target_id' => $Id,
'status' => 'done',
'migrated_at' => now(),
'created_at' => now(),
'updated_at' => now()
]);
});
$this->newLine();
}
$this->info("Migration done");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
#[Signature('migrate:users:configure')]
#[Description('Configure users migrations settings like roles.')]
class MigrateUsersConfigure extends Command
{
private function getWpRoles(): array
{
$wpRoles = ['administrator', 'editor', 'author', 'contributor', 'subscriber'];
$customWpRoles = ['bot'];
return array_merge($wpRoles, $customWpRoles);
}
private function getOldXfGroups(): array|Collection
{
return DB::connection('old_xf')->table('user_group')->pluck('title', 'user_group_id');
}
public function handle()
{
$wpRoles = $this->getWpRoles();
$roleMap = [];
foreach ($wpRoles as $role) {
$roleMap[$role] = (int) $this->ask("XenForo group ID linked to role {$role}");
}
DB::table('migration_settings')
->updateOrInsert([
'key' => 'wp_role_to_xf_group'
], [
'value' => json_encode($roleMap), 'updated_at' => now()
]);
$oldXfGroups = $this->getOldXfGroups();
$groupMap = [];
foreach ($oldXfGroups as $id => $title) {
$groupMap[$id] = (int) $this->ask("Xenforo group ID linked to old XF group {$title}|{$id}");
}
DB::table('migration_settings')
->updateOrInsert([
'key' => 'old_xf_group_to_xf_group'
],[
'value' => json_encode($groupMap), 'updated_at' => now()
]);
$this->info('XF groups updated.');
}
}

View File

@@ -0,0 +1,229 @@
<?php
namespace App\Console\Commands;
use App\Models\MigrationUserPlan;
use App\Services\XenforoApiService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
#[Signature('migrate:users:execute {--limit=} {--wp-uploads-path=} {--xf-data-path=}')]
#[Description("Migrate all users in migration table to XenForo.")]
class MigrateUsersExecute extends Command
{
private function extractWpRole(?string $serialized): string
{
$caps = $serialized ? @unserialize($serialized) : null;
return is_array($caps) ? (array_key_first($caps) ?: 'contributor') : 'contributor';
}
private function extractWpAvatarPath(?string $serialized, string $wpUploadsPath ): ?string
{
$data = $serialized ? @unserialize($serialized) : null;
$mediaId = $data['media_id'] ?? null;
if( !$mediaId )
return null;
$relative = DB::connection('old_wp')
->table('postmeta')
->where('post_id', $mediaId)
->where('meta_key', '_wp_attached_file')
->value('meta_value');
if( !$relative )
return null;
$path = rtrim($wpUploadsPath, '/') . '/' . $relative;
return is_file($path) ? $path : null;
}
private function extractXfAvatarPath(int $userId, string $xfDataPath, string $size = 'o' ): ?string
{
$path = sprintf('%s/avatars/%s/%d/%d.jpg',
rtrim($xfDataPath, '/'), $size, (int) floor($userId / 1000), $userId
);
return is_file($path) ? $path : null;
}
private function extractXfBannerPath(int $userId, string $xfDataPath, string $size = 'l' ): ?string
{
$path = sprintf('%s/profile_banners/%s/%d/%d.jpg',
rtrim($xfDataPath, '/'), $size, (int) floor($userId / 1000), $userId
);
return is_file($path) ? $path : null;
}
private function buildUserInfos( MigrationUserPlan $plan, array $roleMap, array $groupMap, string $wpUploadsPath, string $xfDataPath ): array
{
$infos = [
'username' => $plan->xf_username ?: $plan->wp_username,
'email' => $plan->email,
'user_group_id' => null,
'password' => Str::uuid(), // Used for API verifications.
'xf_user_id' => 0,
'avatar_path' => null,
'banner_path' => null,
'register_date' => null,
'profile' => [
'about' => null,
'website' => null,
],
'source_real_password' => null,
'wp_password' => null,
'xf_scheme_class' => null,
'xf_password_data' => null,
];
if( $plan->wp_user_id )
{
$wp = DB::connection('old_wp')
->table('users')
->leftJoin('usermeta as m1', fn( $j ) => $j->on('users.ID', '=', 'm1.user_id')->where('m1.meta_key', '=', 'description') )
->leftJoin('usermeta as m2', fn( $j ) => $j->on('users.ID', '=', 'm2.user_id')->where('m2.meta_key', '=', 'wp_capabilities') )
->leftJoin('usermeta as m3', fn( $j ) => $j->on('users.ID', '=', 'm3.user_id')->where('m3.meta_key', '=', 'simple_local_avatar') )
->where('users.ID', '=', $plan->wp_user_id )
->select('m1.meta_value as description', 'm2.meta_value as capabilities', 'm3.meta_value as avatar_meta', 'users.user_url as website', 'users.user_pass as password', 'users.user_registred' )
->first();
$infos['register_date'] = $wp->user_registred ? strtotime($wp->user_registred) : null;
$infos['profile']['about'] = $wp->description;
$infos['profile']['website'] = $wp->website;
$role = $this->extractWpRole($wp->capabilities);
$infos['user_group_id'] = $roleMap[$role] ?? $roleMap['contributor'];
if( $url = $this->extractWpAvatarPath($wp->avatar_meta, $wpUploadsPath)){
$infos['avatar_path'] = $url;
}
$infos['source_real_password'] = 'wp';
$infos['wp_password'] = $wp->password;
}
if( $plan->xf_user_id )
{
$xf = DB::connection('old_xf')
->table('user')
->leftJoin('user_profile', 'user.user_id', '=', 'user_profile.user_id')
->leftJoin('user_authenticate', 'user.user_id', '=', 'user_authenticate.user_id')
->where('user.user_id', '=', $plan->xf_user_id)
->select('user.avatar_date', 'user.user_group_id', 'user.register_date', 'user_profile.about', 'user_profile.website', 'user_profile.banner_date', 'user_authenticate.scheme_class', 'user_authenticate.data')
->first();
if( !$infos['register_date'] && $xf )
$infos['register_date'] = $xf->register_date ?: null;
if( !$infos['profile']['about'] && $xf )
$infos['profile']['about'] = $xf->about ?: null;
if( !$infos['profile']['website'] && $xf )
$infos['profile']['website'] = $xf->website ?: null;
if( !$plan->wp_user_id ){
$infos['user_group_id'] = $groupMap[$xf->user_group_id] ?? reset($groupMap);
}
$infos['xf_user_id'] = $plan->xf_user_id;
if( $infos['avatar_path'] === null && (int) $xf->avatar_date > 0){
if( $path = $this->extractXfAvatarPath($plan->xf_user_id, $xfDataPath)){
$infos['avatar_path'] = $path;
}
}
if( $infos['banner_path'] === null && (int) $xf->banner_date > 0 ){
if( $path = $this->extractXfBannerPath($plan->xf_user_id, $xfDataPath)){
$infos['banner_path'] = $path;
}
}
if( $infos['source_real_password'] === null && $xf->scheme_class && $xf->data ){
$infos['source_real_password'] = 'xf';
$infos['xf_scheme_class'] = $xf->scheme_class;
$infos['xf_password_data'] = $xf->data;
}
}
return $infos;
}
private function logMap( string $sourceSystem, string $sourceTable, int $sourceId, int $targetId )
{
DB::table('migrations_logs')->insert([
'source_system' => $sourceSystem,
'source_table' => $sourceTable,
'source_id' => $sourceId,
'target_table' => 'xf_user',
'target_id' => $targetId,
'status' => 'done',
'migrated_at' => now(),
'created_at' => now(),
'updated_at' => now()
]);
}
public function handle()
{
$wpUploadsPath = $this->option('wp-uploads-path');
$xfDataPath = $this->option('xf-data-path');
if( !$wpUploadsPath || !is_dir($wpUploadsPath) ){
$this->error('Missing WP Uploads Path');
return self::FAILURE;
}
if( !$xfDataPath || !is_dir($xfDataPath) ){
$this->error('Missing XF Data Path');
return self::FAILURE;
}
$roleMap = json_decode(DB::table('migration_settings')->where('key', 'wp_role_to_xf_group')->value('value'), true);
$groupMap = json_decode(DB::table('migration_settings')->where('key', 'old_xf_group_to_xf_group')->value('value'), true);
if( !$roleMap || !$groupMap ) {
$this->error('Role map and group map are required.');
return self::FAILURE;
}
$query = MigrationUserPlan::where('status', 'approved')->whereNull('user_id');
if( $limit = $this->option('limit') ) {
$query->limit((int) $limit);
}
$rows = $query->get();
$this->info("{$rows->count()} accounts will be created on XenForo database !!!.");
$ok = $this->ask("Write 'ok' if you want to start the migration. Everything else to quit the migration.");
if( $ok !== 'ok' ) {
return self::SUCCESS;
}
$service = app(XenforoApiService::class);
$this->withProgressBar($rows, function($plan) use( $roleMap, $groupMap, $wpUploadsPath, $xfDataPath, $service ) {
try {
$infos = $this->buildUserInfos( $plan, $roleMap, $groupMap, $wpUploadsPath, $xfDataPath );
[ $userId, $passwordSet ] = $service->_migrateUser( $infos );
if( !$userId ){
throw new \RuntimeException("Error when user creation.");
}
MigrationUserPlan::where('id', $plan->id )->update([ 'user_id' => $userId ]);
$this->logMap( $plan->wp_user_id ? 'wp' : 'xf', $plan->wp_user_id ? 'wp_users' : 'xf_user', $plan->wp_user_id ?? $plan->xf_user_id, $userId );
if( $plan->wp_user_id && $plan->xf_user_id ){
$this->logMap('xf', 'xf_user', $plan->xf_user_id, $userId );
}
} catch ( \Throwable $e ) {
Log::error("Unable to create Plan#{$plan->id} user : {$e->getMessage()}");
}
});
$this->newLine(2);
$this->info("Process finished.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Console\Commands;
use App\Models\MigrationUserPlan;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('migrate:users:plan {--fresh}')]
#[Description('Construct migration plan for WP/XF accounts.')]
class MigrateUsersPlan extends Command
{
public function handle()
{
if( $this->option('fresh') ) {
MigrationUserPlan::truncate();
}
$this->info("Loading old XF accounts...");
$xfUsers = DB::connection('old_xf')->table('user')
->select('user_id','username','email')
->get()
->keyBy('user_id')
;
$xfUsersByEmail = $xfUsers->groupBy(fn($u) => strtolower($u->email));
$this->info("Loading old WP accounts...");
$wpUsers = DB::connection('old_wp')->table('users')
->leftJoin('usermeta', function ($join) {
$join->on('users.ID', '=', 'usermeta.user_id')->where('usermeta.meta_key', '=', 'xf_user_id');
})
->select('users.ID as wp_id', 'users.user_email as email', 'users.user_login as username', 'usermeta.meta_value as linked_xf_id')
->get();
$linkedXfIds = [];
$this->withProgressBar( $wpUsers, function ($wp) use ($xfUsers, $xfUsersByEmail, &$linkedXfIds) {
$email = strtolower($wp->email);
$linkedId = $wp->linked_xf_id ? (int) $wp->linked_xf_id : null;
if( $linkedId && $xfUsers->has($linkedId) ) {
$xf = $xfUsers->get($linkedId);
$matchType = strtolower($xf->email) === $email ? 'explicit' : 'conflict';
MigrationUserPlan::updateOrCreate(
['wp_user_id' => $wp->wp_id],
[
'xf_user_id' => $linkedId,
'match_type' => $matchType,
'email' => $email,
'wp_username' => $wp->username,
'xf_username' => $xf->username,
'note' => $matchType === 'conflict' ? "E-Mail différent: {$xf->email}" : null,
'status' => $matchType === 'explicit' ? 'approved' : 'pending',
]
);
$linkedXfIds[$linkedId] = true;
return;
}
if( $linkedId && !$xfUsers->has($linkedId) ) {
MigrationUserPlan::updateOrCreate(
['wp_user_id' => $wp->wp_id ],
[
'match_type' => 'conflict',
'email' => $email,
'wp_username' => $wp->username,
'note' => "xf_user_id={$linkedId} introuvable",
'status' => 'approved'
]);
return;
}
$candidates = $xfUsersByEmail->get($email,collect());
if( $candidates->count() === 1 ){
$xf = $candidates->first();
MigrationUserPlan::updateOrCreate(
['wp_user_id' => $wp->wp_id],
[
'xf_user_id' => $xf->user_id,
'match_type' => 'email',
'email' => $email,
'wp_username' => $wp->username,
'xf_username' => $xf->username,
'status' => 'pending'
]
);
$linkedXfIds[$xf->user_id] = true;
return;
}
if( $candidates->count() > 1 ){
MigrationUserPlan::updateOrCreate(
['wp_user_id' => $wp->wp_id ],
[
'match_type' => 'conflict',
'email' => $email,
'wp_username' => $wp->username,
'note' => "E-mail identique sur plusieurs comptes XF.",
]
);
return;
}
MigrationUserPlan::updateOrCreate(
['wp_user_id' => $wp->wp_id ],
[
'match_type' => 'wp_only',
'email' => $email,
'wp_username' => $wp->username,
'status' => 'approved'
]
);
});
$this->info("Listing old XF accounts...");
foreach( $xfUsers as $id => $xf ) {
if( isset($linkedXfIds[$id]) ) {
continue;
}
MigrationUserPlan::updateOrCreate(
['xf_user_id' => $xf->user_id, 'wp_user_id' => null],
[
'match_type' => 'xf_only',
'email' => strtolower($xf->email),
'xf_username' => $xf->username,
'status' => 'approved'
]
);
}
$pending = MigrationUserPlan::where('status', 'pending')->count();
$this->newLine(2);
$this->info("Plan generated. {$pending} pending cases.");
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('migrate:xf:configure')]
#[Description("Configure forums map for migrating threads.")]
class MigrateXFConfigure extends Command
{
public function handle()
{
$forums = DB::connection('old_xf')
->table('node')
->where('node_type_id', 'Forum')
->select('node_id', 'title')
->get()
;
$this->info("{$forums->count()} forums found.");
$forumMap = [];
foreach ($forums as $forum) {
$newId = $this->ask("New ID for {$forum->title}");
if( $newId !== null && $newId !== '' ){
$map[$forum->node_id] = (int) $newId;
}
}
DB::table('migration_settings')
->updateOrInsert(
[ 'key' => 'old_xf_node_to_new_xf_node'],
[ 'value' => json_encode($map), 'updated_at' => now() ],
);
$this->info("Config saved.");
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Helpers;
use League\HTMLToMarkdown\HtmlConverter;
class MigrationHelpers
{
public static function wpAutoP(string $content): string
{
$content = trim($content);
if ($content === '') {
return '';
}
$content = str_replace(["\r\n", "\r"], "\n", $content);
$blocks = preg_split('/\n\s*\n/', $content);
$blocks = array_map(function ($block) {
$block = trim($block);
if ($block === '') {
return '';
}
if (preg_match('/^<(p|div|table|ul|ol|blockquote|h[1-6]|pre|figure)[\s>]/i', $block)) {
return $block;
}
return '<p>' . nl2br($block, false) . '</p>';
}, $blocks);
return implode("\n\n", array_filter($blocks, fn ($b) => $b !== ''));
}
public static function htmlToMarkdown( ?string $html ): string
{
if (!$html || trim($html) === '') {
return $html;
}
static $converter = null;
if ($converter === null) {
$converter = new HtmlConverter(['hard_break' => true]);
}
return $converter->convert(self::wpAutoP($html));
}
}

View File

@@ -68,7 +68,7 @@ class DynamicLoadController extends Controller
public function activityFeed(Request $request): JsonResponse
{
$availableFilters = ['entries', 'news', 'messages', 'threads', 'clubs'];
$availableFilters = ['entries', 'news', 'messages', 'threads', 'clubs', 'reviews'];
$requested = $request->query('filters')
? explode(',', $request->query('filters'))

View File

@@ -50,8 +50,9 @@ class EntryController extends Controller
Gate::authorize($entryPolicy, $entry);
$comments = EntryHelpers::getLatestComments($entry);
$reviews = $entry->reviews()->orderBy('created_at', 'desc')->limit(10)->get();
return view('entries.show', compact('entry', 'section', 'comments'));
return view('entries.show', compact('entry', 'section', 'comments', 'reviews'));
}

View File

@@ -15,7 +15,7 @@ class HomeController extends Controller
public function index( Request $request ): View {
$filters = [ 'entries', 'news', 'messages', 'threads', 'clubs' ];
$filters = [ 'entries', 'news', 'messages', 'threads', 'clubs', 'reviews' ];
$cookie = $request->cookie('activity_filters');
$activeFilters = $cookie ? array_intersect( json_decode( $cookie, true ) ?? [], $filters ) : $filters;
@@ -32,6 +32,7 @@ class HomeController extends Controller
'messages' => ['label' => 'Posts', 'icon' => 'message-square'],
'threads' => ['label' => 'Threads', 'icon' => 'messages-square'],
'clubs' => ['label' => 'Clubs', 'icon' => 'balloon'],
'reviews' => ['label' => 'Reviews', 'icon' => 'star'],
];
$latestNews = News::published()->latest('created_at')->limit(5)->get();

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers;
use App\Exceptions\SubmissionException;
use App\Http\Requests\StoreReviewRequest;
use App\Models\Entry;
use App\Services\ReviewsService;
use Illuminate\Http\Request;
class ReviewController extends Controller
{
public function __construct(private ReviewsService $services){}
public function index(){
return view('reviews.index');
}
public function store(StoreReviewRequest $request, Entry $entry)
{
try {
$this->services->storeReview( $request, $entry );
return redirect()->route('entries.show', [ 'section' => $entry->type, 'entry' => $entry ])->with('success', "Your review has been published.");
} catch ( SubmissionException $e ) {
return back()->withInput()->withErrors(['error' => $e->getMessage()]);
} catch ( \Exception $e ) {
return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]);
}
}
}

View File

@@ -61,4 +61,10 @@ class ToolsController extends Controller
return view('tools.play', compact('patches', 'emuConfig'));
}
public function hasher( Request $request )
{
return view('tools.hasher');
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckXenForoUserState
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if( \Auth::guest() )
return $next($request);
if( \Auth::user()->security_lock === 'change' )
return $this->deny( $request, "Password must be changed." );
else if( \Auth::user()->security_lock === 'reset' )
return $this->deny( $request, "Password must be reset.");
if( \Auth::user()->user_state === 'valid' )
return $next($request);
else if( \Auth::user()->user_state === 'email_confirm' || \Auth::user()->user_state === 'email_confirm_edit' )
return $this->deny( $request, "You must verify your email address." );
else if( \Auth::user()->user_state === 'email_bounce' )
return $this->deny( $request, "Invalid email address." );
else if( \Auth::user()->user_state === 'rejected' )
return $this->deny( $request, "Your account is currently rejected." );
else if( \Auth::user()->user_state === 'disabled' )
return $this->deny( $request, "Your account is currently disabled." );
return $this->deny($request, "Invalid user state.");
}
private function deny(Request $request, string $reason): Response
{
if($request->expectsJson())
return \response()->json(['error' => 'forbidden', 'reason' => $reason], 403);
return response()->view('pages.user_state', [
'reason' => $reason,
], 403 );
}
}

View File

@@ -73,7 +73,7 @@ class StoreEntryRequest extends FormRequest
if( section_must_be( ['romhacks', 'lua-scripts'], $section ) ){
$rules['modifications'] = 'array|required|min:1';
$rules['modifications.*'] = 'integer|exists:modifications,id';
} else if( section_must_be( 'utilities', $section ) ){
} else if( section_must_be( ['utilities','documents'], $section ) ){
$rules['categories'] = 'array|required|min:1';
$rules['categories.*'] = 'integer|exists:categories,id';
}
@@ -85,7 +85,7 @@ class StoreEntryRequest extends FormRequest
$rules['version'] = 'required|string|max:50';
$rules['release-date'] = 'required|date';
if( section_must_not_be( 'utilities', $section ) ){
if( section_must_not_be( ['utilities', 'documents'], $section ) ){
$rules['status'] = 'required|integer|exists:statuses,id';
} else {
$rules['level'] = 'required|integer|exists:levels,id';
@@ -159,6 +159,7 @@ class StoreEntryRequest extends FormRequest
$rules['owner_user_id'] = [ 'required', 'integer', new XfUserExists ];
$rules['comments_thread_id'] = 'nullable|integer';
$rules['featured'] = 'nullable|boolean';
$rules['refresh_created_at'] = 'nullable|boolean';
}
return $rules;
@@ -167,16 +168,31 @@ class StoreEntryRequest extends FormRequest
public function messages(): array
{
return [
'entry_title.required' => 'Please provide an entry title.',
'slug.unique' => 'An entry with this title already exists. Please choose another title.',
'file_ids.required' => 'Please upload at least one file.',
'file_ids.min' => 'Please upload at least one file.',
'hashes.required' => 'Please add at least one hash.',
'hashes.min' => 'Please add at least one hash.',
'gallery.required' => 'Please add at least one screenshot.',
'gallery.min' => 'Please add at least one screenshot.',
'new-game-platform.required_without' => 'Please choose a platform for the new game.',
'new-game-genre.required_without' => 'Please choose a genre for the new game.',
'files_uuid.required' => 'Please upload at least a file.',
'files_state.required' => 'A file may be corrupted, please reupload it.',
'files_state.*.in' => 'A file state doesn\'t have a standard value.',
'entry_title.required' => 'Please enter a title.',
'modifications.required' => 'Please select at least one modification.',
'categories.required' => 'Please select at least one category.',
'system.required' => 'Please select at least one system.',
'version.required' => 'Please enter a version number.',
'release-date.required' => 'Please enter a valid release date.',
'status.required' => 'Please select a status.',
'level.required' => 'Please select an experience level.',
'description.required' => 'Please enter a description.',
'game_selection_mode.required' => 'Please select a game selection mode.',
'platform_only_id.required' => 'Please select a platform.',
'game_id.required' => 'Please select a game.',
'new-game-title.required' => 'Please enter a game title.',
'new-game-platform.required' => 'Please select a game platform.',
'new-game-genre.required' => 'Please select a game genre.',
'hashes.required' => 'Please send at least one hash.',
'languages.required' => 'Please select at least one language.',
'main-image.required' => 'Please upload a main image.',
'gallery.required' => 'Please upload at least one image for the gallery.',
'authors.required' => 'Please select at least one author.',
'new-authors.required' => 'Please select at least one author.',
'submit-state.required' => 'Please select a submit state.',
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreReviewRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$review = $this->route('review');
if( $review )
return $this->user()->can('update', $review);
return $this->user()->can('create', '\App\Models\EntryReview');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$isEdit = (bool) $this->route('review');
$rules = [];
$rules['rating'] = 'required|numeric|min:1|max:5';
$rules['title'] = 'required|string|max:255';
$rules['description'] = 'required|string';
return $rules;
}
}

View File

@@ -18,7 +18,7 @@ class DeleteXenForoCommentsThread implements ShouldQueue
* Create a new job instance.
*/
public function __construct(
protected int $threadId,
protected ?int $threadId,
)
{
//
@@ -29,6 +29,7 @@ class DeleteXenForoCommentsThread implements ShouldQueue
*/
public function handle(XenforoApiService $service): void
{
$service->deleteThreadWithEntry($this->threadId);
if( $this->threadId )
$service->deleteThreadWithEntry($this->threadId);
}
}

View File

@@ -18,7 +18,7 @@ class RestoreXenForoCommentsThread implements ShouldQueue
* Create a new job instance.
*/
public function __construct(
protected int $threadId,
protected ?int $threadId,
)
{
//
@@ -29,6 +29,7 @@ class RestoreXenForoCommentsThread implements ShouldQueue
*/
public function handle(XenforoApiService $service): void
{
$service->restoreThreadWithEntry($this->threadId);
if( $this->threadId )
$service->restoreThreadWithEntry($this->threadId);
}
}

View File

@@ -128,7 +128,7 @@ class Database extends Component
* Categories mode and/or
* @var string
*/
#[Url(except:['or'])]
#[Url(except:'or')]
public string $categoriesMode = 'or';
/**
@@ -142,7 +142,7 @@ class Database extends Component
* Systems mode and/or
* @var string
*/
#[Url(except:['or'])]
#[Url(except:'or')]
public string $systemsMode = 'or';
/**
@@ -152,6 +152,9 @@ class Database extends Component
#[Url(except:[])]
public array $levels = [];
#[Url(except:null)]
public ?int $userId = null;
/**
* Sort by field.
* @var string
@@ -185,7 +188,6 @@ class Database extends Component
'utilities' => 'Utilities',
'documents' => 'Documents',
'lua-scripts' => 'Lua Scripts',
'tutorials' => 'Tutorials',
];
public const int PAGINATION = 30;
@@ -207,12 +209,14 @@ class Database extends Component
public function updatedSystems(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedSystemsMode(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedLevels(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedUserId(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function clearFilters(): void
{
$this->reset([
'search', 'types', 'platforms', 'genres', 'statuses', 'authors', 'authorsMode', 'languages', 'languagesMode', 'modifications', 'modificationsMode', 'categories', 'categoriesMode', 'systems', 'systemsMode', 'levels'
'search', 'types', 'platforms', 'genres', 'statuses', 'authors', 'authorsMode', 'languages', 'languagesMode', 'modifications', 'modificationsMode', 'categories', 'categoriesMode', 'systems', 'systemsMode', 'levels', 'userId'
]);
$this->dispatch('filters-updated');
$this->resetPage();
}
@@ -320,18 +324,59 @@ class Database extends Component
}
}
if( $this->userId ){
$query->where('user_id', $this->userId);
}
return $query->orderBy($this->sortBy, $this->sortDir);
}
private function searchFilter( string $modelClass, string $search )
{
$this->dispatch('filters-updated');
if( mb_strlen( $search ) < 3 ) {
return collect();
}
return $modelClass::where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(50)
->get();
}
private function searchGameFilter()
{
$search = $this->gameSearch;
$this->dispatch('filters-updated');
if( mb_strlen( $search ) < 3 ) {
return collect();
}
$collect = Game::where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(50)
->get();
return $collect->map(function($item){
$item->name = $item->name . ' (' . ($item->platform?->short_name ?? $item->platform->name) . ')';
return $item;
} );
}
public function render()
{
return view('livewire.database', [
'entries' => $this->buildQuery()->paginate(self::PAGINATION),
'allGames' => Game::orderBy('name')->get(),
'allGames' => $this->searchGameFilter(),
'allPlatforms' => Platform::orderBy('name')->get(),
'allGenres' => Genre::orderBy('name')->get(),
'allStatuses' => Status::orderBy('name')->get(),
'allAuthors' => Author::orderBy('name')->get(),
'allAuthors' => $this->searchFilter(Author::class, $this->authorSearch),
'allLanguages' => Language::orderBy('name')->get(),
'allModifications' => Modification::orderBy('name')->get(),
'allCategories' => Category::orderBy('name')->get(),

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Livewire;
use App\Helpers\HashesHelpers;
use App\Models\EntryHash;
use App\Models\Game;
use App\Models\Genre;
use App\Models\Platform;
use Illuminate\View\View;
use Livewire\Component;
/**
* @phpstan-import-type HashObject from \App\Types\SubmissionTypes
*/
class HashesChecker extends Component
{
/**
* Current hash.
* @var null|HashObject $hash
*/
public ?array $hash = null;
/**
* Add a hash.
*
* @param string $filename
* @param string $crc32
* @param string $sha1
* @param string|null $verified
*
* @return void
*/
public function addHash(string $filename, string $crc32, string $sha1, ?string $verified = null): void
{
if( $verified !== null && $verified !== "No" )
$this->hash = [
'filename' => $filename,
'hash_crc32' => $crc32,
'hash_sha1' => $sha1,
'verified' => $verified
];
else if( ( $hash = HashesHelpers::findHashes( $sha1 ) ) !== null )
$this->hash = [
'filename' => $hash->filename,
'hash_crc32' => $hash->crc32,
'hash_sha1' => $hash->sha1,
'verified' => HashesHelpers::getReferenceName( $hash->dat_reference_id )
];
else
$this->hash = [
'filename' => $filename,
'hash_crc32' => $crc32,
'hash_sha1' => $sha1,
'verified' => "No"
];
}
/**
* Remove the hash.
*
* @return void
*/
public function removeHash(): void
{
$this->hash = null;
$this->dispatch('refresh');
}
public function render(): View
{
$entries = collect();
if( $this->hash !== null ){
$entries = EntryHash::where('hash_sha1', $this->hash['hash_sha1'] )->get()->pluck('entry')->filter();
}
return view('livewire.hashes-checker', compact('entries'));
}
}

92
app/Livewire/Reviews.php Normal file
View File

@@ -0,0 +1,92 @@
<?php
namespace App\Livewire;
use App\Models\EntryReview;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class Reviews extends Component
{
use WithPagination;
#[Url(except:null)]
public ?int $entryId = null;
#[Url(except:null)]
public ?int $rating = null;
/**
* Sort by field.
* @var string
*/
#[Url(as: 'sort',except: 'created_at')]
public string $sortBy = 'created_at';
/**
* asc/desc
* @var string
*/
#[Url(as: 'dir',except: 'desc')]
public string $sortDir = 'desc';
/**
* Translation of sort options key.
*/
public const array SORT_OPTIONS = [
'created_at' => 'Date added',
'rating' => 'Rating',
'title' => 'Title'
];
public const int PAGINATION = 30;
public function updatedEntryId(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function updatedRating(): void { $this->resetPage(); $this->dispatch('filters-updated'); }
public function clearFilters(): void
{
$this->reset([
'entryId', 'rating'
]);
$this->dispatch('filters-updated');
$this->resetPage();
}
public function setSort(string $field): void
{
if( $this->sortBy === $field ) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $field;
$this->sortDir = 'asc';
}
$this->resetPage();
$this->dispatch('filters-updated');
}
private function buildQuery()
{
$query = EntryReview::query()->with([
'entry'
]);
if( $this->entryId ) {
$query->where('entry_id', $this->entryId);
}
if( $this->rating ){
$query->where('rating', $this->rating);
}
return $query->orderBy($this->sortBy, $this->sortDir);
}
public function render()
{
return view('livewire.reviews', [
'reviews' => $this->buildQuery()->paginate(self::PAGINATION),
]);
}
}

View File

@@ -4,12 +4,14 @@ namespace App\Models;
use App\Helpers\EntryHelpers;
use App\Traits\HasGallery;
use App\Traits\HasXenforoUserId;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use League\CommonMark\GithubFlavoredMarkdownConverter;
use Monolog\Level;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
@@ -97,12 +99,17 @@ use Spatie\Activitylog\Support\LogOptions;
* @method static Builder<static>|Entry withoutTrashed()
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Spatie\Activitylog\Models\Activity> $activitiesAsSubject
* @property-read int|null $activities_as_subject_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\EntryReview> $reviews
* @property-read int|null $reviews_count
* @property-read float $average_rating
* @property-read int $reviews_count_cached
* @property-read string $description_html
* @mixin \Eloquent
*/
class Entry extends Model
{
use SoftDeletes, HasGallery, LogsActivity;
use SoftDeletes, HasGallery, LogsActivity, HasXenforoUserId;
/**
* @var string[]
@@ -143,6 +150,15 @@ class Entry extends Model
'featured_at' => 'datetime',
];
protected static function booted(): void {
static::saving( function( $entry ) {
if( $entry->isDirty('version') ) {
$entry->created_at = now();
}
});
}
public function scopePublished( Builder $query ): Builder {
return $query->where( 'state', 'published' );
}
@@ -207,6 +223,30 @@ class Entry extends Model
return $this->hasMany(EntryHash::class);
}
public function reviews(): HasMany {
return $this->hasMany(EntryReview::class);
}
public function getAverageRatingAttribute(): float
{
return round( $this->reviews->avg('rating') ?? 0, 1 );
}
public function getReviewsCountCachedAttribute(): int
{
return $this->reviews->count();
}
public function getDescriptionHtmlAttribute(): string
{
$converter = new GithubFlavoredMarkdownConverter([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
return $converter->convert($this->description)->getContent();
}
public function parseStaffCredits(): ?array {
return json_decode( $this->staff_credits ?? "", true );
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Models;
use App\Traits\HasXenforoUserId;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use League\CommonMark\GithubFlavoredMarkdownConverter;
/**
* @property int $id
* @property int $entry_id
* @property string $title
* @property int $rating
* @property string $description
* @property string|null $deleted_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Entry|null $entry
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview whereEntryId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview whereRating($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview whereTitle($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview whereUpdatedAt($value)
* @property int $user_id
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview whereUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview withTrashed(bool $withTrashed = true)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EntryReview withoutTrashed()
* @property-read string $description_html
* @mixin \Eloquent
*/
class EntryReview extends Model
{
use HasXenforoUserId, SoftDeletes;
protected $fillable = [ 'entry_id', 'title', 'rating', 'description', 'user_id' ];
public function entry(): BelongsTo
{
return $this->belongsTo(Entry::class);
}
public function getDescriptionHtmlAttribute(): string
{
$converter = new GithubFlavoredMarkdownConverter([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
return $converter->convert($this->description)->getContent();
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan query()
* @property int $id
* @property int|null $wp_user_id
* @property int|null $xf_user_id
* @property string $match_type
* @property string|null $email
* @property string|null $wp_username
* @property string|null $xf_username
* @property string|null $note
* @property string $status
* @property int|null $user_id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereEmail($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereMatchType($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereNote($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereWpUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereWpUsername($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereXfUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereXfUsername($value)
* @property int $wp_password_bridge
* @method static \Illuminate\Database\Eloquent\Builder<static>|MigrationUserPlan whereWpPasswordBridge($value)
* @mixin \Eloquent
*/
class MigrationUserPlan extends Model
{
protected $table = 'migration_user_plan';
protected $fillable = [
'wp_user_id',
'xf_user_id',
'match_type',
'email',
'wp_username',
'xf_username',
'note',
'status',
];
}

View File

@@ -4,10 +4,12 @@ namespace App\Models;
use App\Helpers\EntryHelpers;
use App\Traits\HasGallery;
use App\Traits\HasXenforoUserId;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use League\CommonMark\GithubFlavoredMarkdownConverter;
/**
* @property int $id
@@ -59,7 +61,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class News extends Model
{
use SoftDeletes, HasGallery;
use SoftDeletes, HasGallery, HasXenforoUserId;
protected $table = 'news';
@@ -108,6 +110,16 @@ class News extends Model
return $this->belongsTo(Category::class);
}
public function getDescriptionHtmlAttribute(): string
{
$converter = new GithubFlavoredMarkdownConverter([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
return $converter->convert($this->description)->getContent();
}
public function getYoutubeVideoId(): ?string {
if( !$this->youtube_link )
return null;

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Policies;
use App\Models\Entry;
use App\Models\EntryReview;
use App\Models\User;
class EntryReviewPolicy
{
public function viewAny(\App\Auth\XenForoUser $user): bool
{
if( $user->_can( 'romhackplaza', 'view' ) )
return true;
return false;
}
public function create(\App\Auth\XenForoUser $user, ?EntryReview $review = null ): bool
{
return $user->_can( 'romhackplaza', 'canSubmitEntry' );
}
public function update(\App\Auth\XenForoUser $user, ?EntryReview $review = null ): bool
{
// Staff editors
if( $user->_can('romhackplaza', 'canEditOthersEntries') )
return true;
// Author.
if( $user->_can( 'romhackplaza', 'canEditMyEntries' ) && $review->user_id === $user->user_id )
return true;
return false;
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Providers;
use App\Auth\XenForoGuard;
use App\Auth\XenForoUser;
use App\Policies\TempFilePolicy;
use App\Proxy\VisitorProxy;
use App\Services\TemporaryFileService;
use App\Support\XenForoCauserResolver;
use Illuminate\Support\Facades\Gate;
@@ -39,5 +40,10 @@ class AppServiceProvider extends ServiceProvider
Gate::define('is-mod', function (XenForoUser $user) {
return $user->is_moderator === 1;
});
\View::composer('*', function ($view) {
$view->with('VISITOR', new VisitorProxy( \Auth::user() ) );
});
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Proxy;
use App\Auth\XenForoUser;
use App\Services\XenforoService;
/**
* @mixin XenForoUser
*/
class VisitorProxy
{
private ?XenForoUser $currentVisitor;
private array $users = [];
public function __construct(?XenForoUser $user)
{
$this->currentVisitor = $user;
}
public function __get( string $name ): mixed
{
return $this->currentVisitor?->$name;
}
public function __invoke( int $userId ): ?XenForoUser
{
if( !isset( $this->users[$userId] ) ){
$this->users[$userId] = app(XenforoService::class)->getXfUser($userId);
}
return $this->users[$userId];
}
public function loggedIn(): bool
{
return $this->currentVisitor !== null;
}
public function guest(): bool
{
return $this->currentVisitor === null;
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\Entry;
use App\Models\EntryReview;
use App\Models\News;
use App\View\Components\EntryCard;
use Illuminate\Support\Carbon;
@@ -17,9 +18,10 @@ class ActivityService
private const CACHE_MESSAGES = 300; // seconds.
private const CACHE_THREADS = 300; // seconds.
private const CACHE_CLUBS = 300; // seconds.
private const CACHE_REVIEWS = 300; // seconds.
private const ITEMS_PER_TYPE = 15;
public function getActivities( array $activities = [ 'entries', 'news', 'messages', 'threads', 'clubs' ] ): Collection
public function getActivities( array $activities = [ 'entries', 'news', 'messages', 'threads', 'clubs', 'reviews' ] ): Collection
{
$c = collect();
if( in_array( 'entries', $activities ) ) {
@@ -37,6 +39,9 @@ class ActivityService
if( in_array( 'clubs', $activities ) ) {
$c = $c->merge($this->getClubs());
}
if( in_array( 'reviews', $activities ) ) {
$c = $c->merge($this->getReviews());
}
return $c->sortByDesc('date')
->values()
@@ -135,6 +140,23 @@ class ActivityService
];
}
private function formatReview( EntryReview $review ): array
{
return [
'type' => 'review',
'title' => $review->title,
'url' => $review->entry()->exists() ? route('entries.show', ['section' => $review->entry->type, 'entry' => $review->entry]) : '',
'image' => null,
'date' => $review->created_at->timestamp,
'author' => null,
'user_id' => $review->user_id,
'badge' => 'Review',
'badge_class' => 'review',
'excerpt' => $review->description ? \Str::limit(strip_tags($review->description), 80) : null,
'meta' => $review->entry()->exists() ? ( $review->entry->complete_title ?? $review->entry->title ) : null,
];
}
private function getEntries(): array
{
return Cache::remember('activity_entries', self::CACHE_ENTRIES, function() {
@@ -220,4 +242,16 @@ class ActivityService
->toArray();
});
}
private function getReviews(): array
{
return Cache::remember('activity_reviews', self::CACHE_REVIEWS, function() {
return EntryReview::with(['entry'])
->latest('created_at')
->limit(self::ITEMS_PER_TYPE)
->get()
->map($this->formatReview(...))
->toArray();
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Services;
use App\Helpers\EntryHelpers;
use App\Models\Entry;
use App\Models\EntryReview;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ReviewsService
{
private ?Request $request = null;
private ?Entry $entry = null;
private ?EntryReview $entryReview = null;
/**
* @throws \Throwable
*/
public function storeReview(Request $request, Entry $entry)
{
// Step 1: Prepare fields.
$this->request = $request;
$this->entry = $entry;
$user_id = \Auth::user()->user_id;
$review = DB::transaction(function () use ($user_id) {
$fields = [
'entry_id' => $this->entry->id,
'title' => $this->request->input('title'),
'rating'=> $this->request->input('rating'),
'description' => $this->request->input('description'),
'user_id' => $user_id,
];
$review = EntryReview::create($fields);
return $review;
});
return $review;
}
}

View File

@@ -201,7 +201,7 @@ class SubmissionsService {
$this->Step11_SaveLanguages( $entry );
// STEP 11.5 : Save Categories
if( section_must_be( 'utilities', $this->section ) ) {
if( section_must_be( ['utilities', 'documents'], $this->section ) ) {
$this->Step11_5_SaveCategories($entry);
}
@@ -605,6 +605,9 @@ class SubmissionsService {
if( $fields['featured'] == false )
$fields['featured_at'] = null;
$fields['comments_thread_id'] = $this->request->input('comments_thread_id');
$refresh_created_at = $this->request->input('refresh_created_at') ?? false;
if( $refresh_created_at )
$fields['created_at'] = now();
}
$this->entry->update( $fields );
@@ -631,7 +634,7 @@ class SubmissionsService {
$this->eStep10_UpdateLanguages();
// STEP 10.5 : Update categories
if( section_must_be( 'utilities', $this->section ) )
if( section_must_be( ['utilities', 'documents'], $this->section ) )
$this->eStep10_5_UpdateCategories();
// STEP 11: Prepare new gallery images and prepare deletion of others ones.

View File

@@ -24,7 +24,7 @@ class XenforoApiService {
*/
private function get(string $endpoint, ?int $customUserId = null ): mixed
{
$response = Http::withHeaders([
$response = Http::timeout(30)->withHeaders([
'XF-Api-Key' => $this->apiKey,
'XF-Api-User' => $customUserId ?? $this->superUserId,
])->get("{$this->apiUrl}/{$endpoint}");
@@ -37,7 +37,7 @@ class XenforoApiService {
private function post(string $endpoint, ?int $customUserId = null, array $data = [] ): mixed
{
$response = Http::withHeaders([
$response = Http::timeout(30)->withHeaders([
'XF-Api-Key' => $this->apiKey,
'XF-Api-User' => $customUserId ?? $this->superUserId,
])->post("{$this->apiUrl}/{$endpoint}", $data);
@@ -50,7 +50,7 @@ class XenforoApiService {
private function delete(string $endpoint, ?int $customUserId = null, array $data = [] ): mixed
{
$response = Http::withHeaders([
$response = Http::timeout(30)->withHeaders([
'XF-Api-Key' => $this->apiKey,
'XF-Api-User' => $customUserId ?? $this->superUserId,
])->delete("{$this->apiUrl}/{$endpoint}", $data);
@@ -75,7 +75,7 @@ class XenforoApiService {
public function markAllNotificationsRead(int $userId): void
{
Cache::forget("xf_alerts_{$userId}");
$this->post("alerts/marl-all", $userId );
$this->post("alerts/mark-all", $userId );
}
public function getConversations(int $userId): mixed
@@ -87,8 +87,9 @@ class XenforoApiService {
public function createConversation( array $userIdList, string $title, string $message, bool $conversationOpen, bool $openInvite ): bool
{
$response = $this->post("conversations", data: ['recipient_ids' => $userIdList, 'title' => $title, 'message' => $message, 'open_invite' => $openInvite, 'conversation_open' => $conversationOpen] );
$response = $this->post("conversations",
data: ['recipient_ids' => $userIdList, 'title' => $title, 'message' => $message, 'open_invite' => $openInvite, 'conversation_open' => $conversationOpen]
);
return $response['success'] ?? false;
}
@@ -152,4 +153,13 @@ class XenforoApiService {
return (bool) $this->post("threads/{$threadId}/undelete" );
}
public function _migrateUser(array $data): array
{
$response = $this->post("migrate/user", data: $data );
if( !$response || !$response['success'] )
return [false,false];
return [ $response['user_id'] ?? false, $response['password_set'] ?? false ];
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Traits;
use App\Auth\XenForoUser;
use App\Services\XenforoService;
trait HasXenforoUserId
{
public function xenforoUser(): ?XenForoUser
{
$service = app(XenforoService::class);
return $service->getXfUser( $this->user_id );
}
}

View File

@@ -17,7 +17,7 @@ class DatabaseFilterWithModeSearch extends Component
public string $model,
public string $modeModel,
public string $selectedMode,
public string $searchModel,
public string $idProperty = 'id',
public string $nameProperty = 'name',
)

View File

@@ -15,7 +15,7 @@ class DatabaseFilterWithoutModeSearch extends Component
public string $title,
public $items,
public string $model,
public string $searchModel,
public string $idProperty = 'id',
public string $nameProperty = 'name',
)

View File

@@ -17,6 +17,10 @@ class ErrorBlock extends Component
'page-not-allowed' => [
'icon' => 'shield-ban',
'message' => "You do not have permission to access this page.\nRequired permission: %s"
],
'user-state-not-valid' => [
'icon' => 'shield-ban',
'message' => "You do not have permission to access this page.\nYour user profile is incomplete: %s\nGo back to the forum for more details."
]
];

View File

@@ -0,0 +1,30 @@
<?php
namespace App\View\Components;
use App\Models\EntryReview;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class ReviewCard extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public EntryReview $review,
public bool $entryShow = false
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.review-card');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\View\Components;
use Closure;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class ReviewStarRating extends Component
{
/**
* Create a new component instance.
*/
public function __construct(
public float $rating,
)
{
//
}
/**
* Get the view / contents that represent the component.
*/
public function render(): View|Closure|string
{
return view('components.review-star-rating');
}
}

View File

@@ -1,5 +1,7 @@
<?php
use League\HTMLToMarkdown\HtmlConverter;
/* SECTIONS HELPERS */
if( !function_exists( 'section_must_be' ) ){
@@ -34,9 +36,9 @@ if( !function_exists('userTheme' ) ){
}
}
if( !function_exists( 'databaseRoute' ) ){
if( !function_exists( 'databaseRoute' ) ) {
function databaseRoute( array $params = [] ): string
function databaseRoute(array $params = []): string
{
$defaults = [
'types' => [],
@@ -50,6 +52,11 @@ if( !function_exists( 'databaseRoute' ) ){
'languagesMode' => 'or',
'modifications' => [],
'modificationsMode' => 'or',
'categories' => [],
'categoriesMode' => 'or',
'systems' => [],
'systemsMode' => 'or',
'levels' => [],
'sort' => 'created_at',
'dir' => 'desc',
's' => ''
@@ -57,9 +64,9 @@ if( !function_exists( 'databaseRoute' ) ){
$query = array_filter(
array_merge($defaults, $params),
fn($v,$k) => match(true){
fn($v, $k) => match (true) {
is_array($v) => !empty($v),
in_array($k, ['authorsMode', 'languagesMode', 'modificationsMode']) => $v !== 'or',
in_array($k, ['authorsMode', 'languagesMode', 'modificationsMode', 'categoriesMode', 'systemsMode']) => $v !== 'or',
$k === 'sort' => $v !== 'created_at',
$k === 'dir' => $v !== 'desc',
default => $v !== '',
@@ -67,6 +74,57 @@ if( !function_exists( 'databaseRoute' ) ){
ARRAY_FILTER_USE_BOTH
);
return route('entries.index', $query );
return route('entries.index', $query);
}
}
if( !function_exists( 'newsRoute' ) ){
function newsRoute(array $params = []): string
{
$defaults = [
'categories' => [],
'sort' => 'created_at',
'dir' => 'desc',
's' => ''
];
$query = array_filter(
array_merge($defaults, $params),
fn($v, $k) => match (true) {
is_array($v) => !empty($v),
$k === 'sort' => $v !== 'created_at',
$k === 'dir' => $v !== 'desc',
default => $v !== '',
},
ARRAY_FILTER_USE_BOTH
);
return route('news.index', $query);
}
}
if( !function_exists('reviewsRoute') ){
function reviewsRoute( array $params = [] ): string
{
$defaults = [
'entryId' => null,
'rating' => null,
'sort' => 'created_at',
'dir' => 'desc',
];
$query = array_filter(
array_merge($defaults, $params),
fn($v,$k) => match(true){
is_array($v) => !empty($v),
$k === 'sort' => $v !== 'created_at',
$k === 'dir' => $v !== 'desc',
default => $v !== '',
},
ARRAY_FILTER_USE_BOTH
);
return route('reviews.index', $query );
}
}