837 lines
27 KiB
PHP
837 lines
27 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\MakerWorldCollection;
|
|
use App\Models\Product;
|
|
use App\Models\Setting;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
|
|
class MakerWorldService
|
|
{
|
|
protected string $baseUrl = 'https://api.bambulab.com';
|
|
|
|
protected string $userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
|
|
|
/**
|
|
* Build an HTTP client pre-configured with headers and optional bearer token.
|
|
*/
|
|
protected function client(): PendingRequest
|
|
{
|
|
$headers = [
|
|
'User-Agent' => $this->userAgent,
|
|
'Accept' => 'application/json, text/plain, */*',
|
|
];
|
|
|
|
$token = $this->getAccessToken();
|
|
if ($token) {
|
|
$headers['Authorization'] = 'Bearer '.$token;
|
|
}
|
|
|
|
return Http::withHeaders($headers)->timeout(15);
|
|
}
|
|
|
|
/**
|
|
* Get the stored access token.
|
|
*/
|
|
public function getAccessToken(): ?string
|
|
{
|
|
return Setting::get('makerworld_token');
|
|
}
|
|
|
|
/**
|
|
* Store the access token.
|
|
*/
|
|
public function setAccessToken(string $token): array
|
|
{
|
|
Setting::set('makerworld_token', trim($token));
|
|
$profile = $this->getUserProfile();
|
|
|
|
if ($profile) {
|
|
Setting::set('makerworld_user', $profile);
|
|
|
|
return [
|
|
'success' => true,
|
|
'user' => $profile,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'user' => null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check if currently authenticated with MakerWorld.
|
|
*/
|
|
public function isAuthenticated(): bool
|
|
{
|
|
return ! empty($this->getAccessToken());
|
|
}
|
|
|
|
/**
|
|
* Log out from MakerWorld by clearing saved credentials.
|
|
*/
|
|
public function logout(): void
|
|
{
|
|
Setting::forget('makerworld_token');
|
|
Setting::forget('makerworld_user');
|
|
}
|
|
|
|
/**
|
|
* Attempt to log in with MakerWorld account credentials (email and password).
|
|
*
|
|
* @return array{status: string, message?: string, token?: string, user?: array, account?: string}
|
|
*/
|
|
public function loginWithCredentials(string $account, string $password): array
|
|
{
|
|
try {
|
|
$response = Http::withHeaders([
|
|
'User-Agent' => $this->userAgent,
|
|
'Content-Type' => 'application/json',
|
|
])->post("{$this->baseUrl}/v1/user-service/user/login", [
|
|
'account' => $account,
|
|
'password' => $password,
|
|
'apiError' => '',
|
|
]);
|
|
|
|
$data = $response->json();
|
|
|
|
if (! empty($data['accessToken'])) {
|
|
$this->setAccessToken($data['accessToken']);
|
|
|
|
return [
|
|
'status' => 'success',
|
|
'token' => $data['accessToken'],
|
|
'message' => 'Succesvol ingelogd bij MakerWorld!',
|
|
];
|
|
}
|
|
|
|
// Check if 2FA code is needed (Bambu Lab sends the code automatically when verifyCode is returned)
|
|
if (isset($data['loginType']) && $data['loginType'] === 'verifyCode') {
|
|
return [
|
|
'status' => '2fa_required',
|
|
'account' => $account,
|
|
'message' => 'Verificatiecode vereist. Bambu Lab heeft een 6-cijferige verificatiecode naar je e-mail gestuurd.',
|
|
];
|
|
}
|
|
|
|
$errorMsg = $data['error'] ?? $data['message'] ?? 'Inloggen mislukt. Controleer je MakerWorld account en wachtwoord.';
|
|
|
|
return [
|
|
'status' => 'error',
|
|
'message' => $errorMsg,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('MakerWorld login error: '.$e->getMessage());
|
|
|
|
return [
|
|
'status' => 'error',
|
|
'message' => 'Verbinding met MakerWorld mislukt: '.$e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request an email verification code for Bambu / MakerWorld login.
|
|
*/
|
|
public function sendEmailVerificationCode(string $email): bool
|
|
{
|
|
try {
|
|
$response = Http::withHeaders([
|
|
'User-Agent' => $this->userAgent,
|
|
'Content-Type' => 'application/json',
|
|
])->post("{$this->baseUrl}/v1/user-service/user/sendemail/code", [
|
|
'email' => $email,
|
|
'type' => 'codeLogin',
|
|
]);
|
|
|
|
return $response->successful();
|
|
} catch (\Throwable $e) {
|
|
Log::error('MakerWorld send code error: '.$e->getMessage());
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Complete login with account and email verification code.
|
|
*/
|
|
public function verifyLoginCode(string $account, string $code): array
|
|
{
|
|
try {
|
|
$response = Http::withHeaders([
|
|
'User-Agent' => $this->userAgent,
|
|
'Content-Type' => 'application/json',
|
|
])->post("{$this->baseUrl}/v1/user-service/user/login", [
|
|
'account' => $account,
|
|
'code' => trim($code),
|
|
]);
|
|
|
|
$data = $response->json();
|
|
|
|
if (! empty($data['accessToken'])) {
|
|
$this->setAccessToken($data['accessToken']);
|
|
|
|
return [
|
|
'status' => 'success',
|
|
'token' => $data['accessToken'],
|
|
'message' => 'Verificatie geslaagd! Ingelogd bij MakerWorld.',
|
|
];
|
|
}
|
|
|
|
return [
|
|
'status' => 'error',
|
|
'message' => $data['error'] ?? $data['message'] ?? 'Ongeldige verificatiecode.',
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('MakerWorld verify code error: '.$e->getMessage());
|
|
|
|
return [
|
|
'status' => 'error',
|
|
'message' => 'Verificatie mislukt: '.$e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch user preference and profile.
|
|
*/
|
|
public function getUserProfile(): ?array
|
|
{
|
|
try {
|
|
$response = $this->client()->get("{$this->baseUrl}/v1/design-user-service/my/preference");
|
|
|
|
if ($response->successful()) {
|
|
return $response->json();
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning('MakerWorld getUserProfile failed: '.$e->getMessage());
|
|
}
|
|
|
|
return Setting::get('makerworld_user');
|
|
}
|
|
|
|
/**
|
|
* Safely extract a normalized list of model items from diverse API response formats.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function extractModelsList(mixed $data): array
|
|
{
|
|
if (! is_array($data)) {
|
|
return [];
|
|
}
|
|
|
|
if (isset($data['data']) && is_array($data['data'])) {
|
|
$data = $data['data'];
|
|
}
|
|
|
|
$list = $data['hits'] ?? $data['designs'] ?? $data['list'] ?? $data['items'] ?? null;
|
|
|
|
if (is_array($list)) {
|
|
return array_values(array_filter($list, fn ($item) => is_array($item)));
|
|
}
|
|
|
|
if (array_is_list($data)) {
|
|
return array_values(array_filter($data, fn ($item) => is_array($item)));
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Fetch user's favorited models from MakerWorld.
|
|
*/
|
|
public function getFavorites(int $page = 1, int $limit = 24): array
|
|
{
|
|
if (! $this->isAuthenticated()) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/my/design/favoriteslist", [
|
|
'page' => $page,
|
|
'limit' => $limit,
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
return $this->extractModelsList($response->json());
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error('MakerWorld getFavorites error: '.$e->getMessage());
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Fetch user's published models from MakerWorld.
|
|
*/
|
|
public function getPublished(int $page = 1, int $limit = 24): array
|
|
{
|
|
if (! $this->isAuthenticated()) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/my/design/published", [
|
|
'page' => $page,
|
|
'limit' => $limit,
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
return $this->extractModelsList($response->json());
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error('MakerWorld getPublished error: '.$e->getMessage());
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Fetch popular showcase/contest models from MakerWorld.
|
|
*/
|
|
public function getCommunityShowcase(int $limit = 30): array
|
|
{
|
|
try {
|
|
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/contest/list", [
|
|
'limit' => 3,
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
$allDesigns = [];
|
|
|
|
if (! empty($data['hits']) && is_array($data['hits'])) {
|
|
foreach ($data['hits'] as $contest) {
|
|
if (! empty($contest['designs']) && is_array($contest['designs'])) {
|
|
foreach ($contest['designs'] as $design) {
|
|
if (is_array($design) && ! empty($design['id'])) {
|
|
$allDesigns[$design['id']] = $design;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_slice(array_values($allDesigns), 0, $limit);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error('MakerWorld getCommunityShowcase error: '.$e->getMessage());
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Get detailed design info by ID.
|
|
*/
|
|
public function getDesignDetails(int|string $designId): ?array
|
|
{
|
|
try {
|
|
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/design/{$designId}");
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
if (! empty($data['id']) || ! empty($data['title'])) {
|
|
return $data;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error("MakerWorld getDesignDetails({$designId}) error: ".$e->getMessage());
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get batch of designs by IDs.
|
|
*/
|
|
public function getDesignsByIds(array $designIds): array
|
|
{
|
|
if (empty($designIds)) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$ids = implode(',', array_filter(array_map('intval', $designIds)));
|
|
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/design/ids", [
|
|
'designIds' => $ids,
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
|
|
return $data['designs'] ?? [];
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error('MakerWorld getDesignsByIds error: '.$e->getMessage());
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Parse model ID from full MakerWorld URL or raw ID string.
|
|
* Examples:
|
|
* - https://makerworld.com/nl/models/55866#profileId-60000 -> 55866
|
|
* - https://makerworld.com/en/models/12345?keyword=test -> 12345
|
|
* - 55866 -> 55866
|
|
*/
|
|
public function parseModelId(string $input): ?int
|
|
{
|
|
$input = trim($input);
|
|
|
|
if (is_numeric($input)) {
|
|
return (int) $input;
|
|
}
|
|
|
|
if (preg_match('/models\/(\d+)/i', $input, $matches)) {
|
|
return (int) $matches[1];
|
|
}
|
|
|
|
if (preg_match('/design\/(\d+)/i', $input, $matches)) {
|
|
return (int) $matches[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse a MakerWorld collection URL, handle, or ID.
|
|
* Examples:
|
|
* - https://makerworld.com/nl/@user_1718037659/collections -> type: user_collections, handle: user_1718037659
|
|
* - https://makerworld.com/nl/collections/9205051 -> type: collection, id: 9205051
|
|
* - 9205051 -> type: collection, id: 9205051
|
|
*
|
|
* @return array{type: string, id: ?int, handle: ?string, raw: string}
|
|
*/
|
|
public function parseCollectionIdentifier(string $input): array
|
|
{
|
|
$trimmed = trim($input);
|
|
|
|
// Direct numeric ID
|
|
if (is_numeric($trimmed)) {
|
|
return [
|
|
'type' => 'collection',
|
|
'id' => (int) $trimmed,
|
|
'handle' => null,
|
|
'raw' => $trimmed,
|
|
];
|
|
}
|
|
|
|
// Collection URL: /collections/12345
|
|
if (preg_match('/collections\/(\d+)/i', $trimmed, $matches)) {
|
|
return [
|
|
'type' => 'collection',
|
|
'id' => (int) $matches[1],
|
|
'handle' => null,
|
|
'raw' => $trimmed,
|
|
];
|
|
}
|
|
|
|
// User collections URL: /@user_1718037659/collections
|
|
if (preg_match('/@([a-zA-Z0-9_\-]+)\/collections/i', $trimmed, $matches)) {
|
|
return [
|
|
'type' => 'user_collections',
|
|
'id' => null,
|
|
'handle' => $matches[1],
|
|
'raw' => $trimmed,
|
|
];
|
|
}
|
|
|
|
// Just handle: @user_1718037659
|
|
if (str_starts_with($trimmed, '@')) {
|
|
return [
|
|
'type' => 'user_collections',
|
|
'id' => null,
|
|
'handle' => ltrim($trimmed, '@'),
|
|
'raw' => $trimmed,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'type' => 'unknown',
|
|
'id' => null,
|
|
'handle' => null,
|
|
'raw' => $trimmed,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Fetch user's favorite collections from MakerWorld.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function getFavoritesCollections(): array
|
|
{
|
|
$collections = [];
|
|
|
|
// If authenticated, get logged-in user's collections
|
|
if ($this->isAuthenticated()) {
|
|
try {
|
|
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/my/favorites");
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
$list = $this->extractModelsList($data);
|
|
if (! empty($list)) {
|
|
return $list;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning('MakerWorld getFavoritesCollections (auth) failed: '.$e->getMessage());
|
|
}
|
|
}
|
|
|
|
// Public/featured collections tab fallback
|
|
try {
|
|
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/favorites-collections/tab");
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
if (! empty($data['hits']) && is_array($data['hits'])) {
|
|
return $data['hits'];
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning('MakerWorld getFavoritesCollections fallback failed: '.$e->getMessage());
|
|
}
|
|
|
|
return $collections;
|
|
}
|
|
|
|
/**
|
|
* Fetch details of a specific collection by collection ID.
|
|
*/
|
|
public function getCollectionDetails(int|string $collectionId): ?array
|
|
{
|
|
try {
|
|
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/favorites/{$collectionId}");
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
if (! empty($data['id']) || ! empty($data['title'])) {
|
|
return $data;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error("MakerWorld getCollectionDetails({$collectionId}) error: ".$e->getMessage());
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Fetch all design models inside a MakerWorld collection.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function getCollectionDesigns(int|string $collectionId, int $page = 1, int $limit = 100): array
|
|
{
|
|
$details = $this->getCollectionDetails($collectionId);
|
|
|
|
if ($details && ! empty($details['designs']) && is_array($details['designs'])) {
|
|
return $details['designs'];
|
|
}
|
|
|
|
try {
|
|
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/favorites/designs/{$collectionId}", [
|
|
'page' => $page,
|
|
'limit' => $limit,
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
$data = $response->json();
|
|
|
|
return $this->extractModelsList($data);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::error("MakerWorld getCollectionDesigns({$collectionId}) error: ".$e->getMessage());
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Transform raw MakerWorld API design details into a clean format for product creation.
|
|
*/
|
|
public function formatDesignForImport(array $design): array
|
|
{
|
|
$designId = (int) ($design['id'] ?? 0);
|
|
$title = trim($design['title'] ?? 'MakerWorld 3D Model #'.$designId);
|
|
|
|
// Summary / Description cleanup
|
|
$rawSummary = $design['summary'] ?? $design['summaryTranslated'] ?? '';
|
|
$cleanDescription = $this->sanitizeHtml($rawSummary);
|
|
$shortDescription = Str::limit(strip_tags($rawSummary), 280);
|
|
|
|
// Cover & Gallery Images
|
|
$coverUrl = $design['coverUrl'] ?? $design['cover'] ?? null;
|
|
$gallery = [];
|
|
|
|
if (! empty($design['designExtension']['design_pictures']) && is_array($design['designExtension']['design_pictures'])) {
|
|
foreach ($design['designExtension']['design_pictures'] as $pic) {
|
|
if (! empty($pic['url']) && $pic['url'] !== $coverUrl && ! in_array($pic['url'], $gallery, true)) {
|
|
$gallery[] = $pic['url'];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (! empty($design['designExtension']['real_pictures']) && is_array($design['designExtension']['real_pictures'])) {
|
|
foreach ($design['designExtension']['real_pictures'] as $pic) {
|
|
if (! empty($pic['url']) && $pic['url'] !== $coverUrl && ! in_array($pic['url'], $gallery, true)) {
|
|
$gallery[] = $pic['url'];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract print profile stats & instance information
|
|
$weightGrams = null;
|
|
$printTimeHours = null;
|
|
$materials = ['pla', 'petg'];
|
|
$printInfo = [];
|
|
$dimensions = null;
|
|
|
|
if (! empty($design['instances']) && is_array($design['instances'])) {
|
|
$profile = $design['instances'][0];
|
|
|
|
if (! empty($profile['weight'])) {
|
|
$weightGrams = (int) round($profile['weight']);
|
|
}
|
|
|
|
if (! empty($profile['prediction'])) {
|
|
$seconds = (int) $profile['prediction'];
|
|
$printTimeHours = round($seconds / 3600, 1);
|
|
}
|
|
|
|
// Extract instance photos if available
|
|
if (! empty($profile['pictures']) && is_array($profile['pictures'])) {
|
|
foreach ($profile['pictures'] as $p) {
|
|
$url = is_array($p) ? ($p['url'] ?? null) : $p;
|
|
if ($url && $url !== $coverUrl && ! in_array($url, $gallery, true)) {
|
|
$gallery[] = $url;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Structured print info
|
|
$printInfo = [
|
|
'profile_title' => $profile['title'] ?? null,
|
|
'printer' => $profile['printerName'] ?? $profile['modelName'] ?? 'Bambu Lab Printer',
|
|
'nozzle' => $profile['nozzleDiameter'] ?? '0.4 mm',
|
|
'weight_grams' => $weightGrams,
|
|
'print_time_hours' => $printTimeHours,
|
|
'prediction_seconds' => $profile['prediction'] ?? null,
|
|
'plate_count' => count($profile['plates'] ?? []) ?: 1,
|
|
'filaments' => $profile['filamentConfig'] ?? $profile['filaments'] ?? [],
|
|
];
|
|
}
|
|
|
|
// Dimensions if specified
|
|
if (! empty($design['dimensions'])) {
|
|
$dimensions = is_array($design['dimensions']) ? implode(' x ', $design['dimensions']) : (string) $design['dimensions'];
|
|
}
|
|
|
|
// Calculate a smart suggested retail price
|
|
// Base starting price of 9.95, + ~0.08 per gram + 1.25 per print hour
|
|
$suggestedPrice = 14.95;
|
|
if ($weightGrams || $printTimeHours) {
|
|
$cost = 5.0;
|
|
if ($weightGrams) {
|
|
$cost += ($weightGrams * 0.07);
|
|
}
|
|
if ($printTimeHours) {
|
|
$cost += ($printTimeHours * 1.50);
|
|
}
|
|
$suggestedPrice = round(max(9.95, $cost * 1.6), 2);
|
|
}
|
|
|
|
// Creator and MakerWorld URL
|
|
$authorName = $design['designCreator']['name'] ?? $design['designCreator']['handle'] ?? 'MakerWorld Creator';
|
|
$makerworldUrl = "https://makerworld.com/nl/models/{$designId}";
|
|
$license = $design['license'] ?? 'Standard MakerWorld License';
|
|
|
|
return [
|
|
'makerworld_id' => $designId,
|
|
'name' => $title,
|
|
'slug' => Str::slug($title),
|
|
'short_description' => $shortDescription,
|
|
'description' => $cleanDescription,
|
|
'base_price' => $suggestedPrice,
|
|
'sku' => 'MW-'.$designId,
|
|
'is_print_on_demand' => true,
|
|
'stock' => 15,
|
|
'is_featured' => false,
|
|
'is_active' => true,
|
|
'status' => 'published',
|
|
'dimensions' => $dimensions,
|
|
'weight_grams' => $weightGrams,
|
|
'print_time_hours' => $printTimeHours,
|
|
'print_info' => $printInfo,
|
|
'image_url' => $coverUrl,
|
|
'makerworld_url' => $makerworldUrl,
|
|
'makerworld_author' => $authorName,
|
|
'makerworld_license' => $license,
|
|
'gallery_images' => $gallery,
|
|
'available_materials' => $materials,
|
|
'available_colors' => ['Mat Zwart', 'Sneeuwwit', 'Leisteen Grijs', 'Koningsblauw'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Import or update a product from MakerWorld data.
|
|
*/
|
|
public function importDesign(
|
|
array $designData,
|
|
?int $categoryId = null,
|
|
?float $customPrice = null,
|
|
?string $customName = null,
|
|
bool $isActive = true,
|
|
string $status = 'published',
|
|
?int $collectionId = null
|
|
): Product {
|
|
$formatted = $this->formatDesignForImport($designData);
|
|
|
|
if ($customPrice !== null && $customPrice > 0) {
|
|
$formatted['base_price'] = $customPrice;
|
|
}
|
|
|
|
if ($customName !== null && trim($customName) !== '') {
|
|
$formatted['name'] = trim($customName);
|
|
$formatted['slug'] = Str::slug($formatted['name']);
|
|
}
|
|
|
|
if ($categoryId !== null) {
|
|
$formatted['category_id'] = $categoryId;
|
|
}
|
|
|
|
$formatted['is_active'] = $isActive;
|
|
$formatted['status'] = $status;
|
|
|
|
if ($collectionId !== null) {
|
|
$formatted['makerworld_collection_id'] = $collectionId;
|
|
}
|
|
|
|
// Ensure slug is unique
|
|
$baseSlug = $formatted['slug'];
|
|
$count = 1;
|
|
while (Product::where('slug', $formatted['slug'])->where('makerworld_id', '!=', $formatted['makerworld_id'])->exists()) {
|
|
$formatted['slug'] = "{$baseSlug}-{$count}";
|
|
$count++;
|
|
}
|
|
|
|
return Product::updateOrCreate(
|
|
['makerworld_id' => $formatted['makerworld_id']],
|
|
$formatted
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Synchronize designs from a MakerWorld collection.
|
|
* New items are staged in 'draft' status (is_active = false) for admin curation.
|
|
*
|
|
* @return array{imported: int, updated: int, total: int, error: ?string}
|
|
*/
|
|
public function syncCollection(MakerWorldCollection $collection): array
|
|
{
|
|
$collection->update([
|
|
'last_sync_status' => 'running',
|
|
'last_sync_message' => 'Synchronisatie bezig...',
|
|
]);
|
|
|
|
try {
|
|
$designs = $this->getCollectionDesigns($collection->makerworld_collection_id);
|
|
|
|
$imported = 0;
|
|
$updated = 0;
|
|
|
|
foreach ($designs as $design) {
|
|
$designId = (int) ($design['id'] ?? 0);
|
|
if ($designId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$existingProduct = Product::where('makerworld_id', $designId)->first();
|
|
|
|
if ($existingProduct) {
|
|
// Associate with collection if not yet linked
|
|
$updates = [];
|
|
if (! $existingProduct->makerworld_collection_id) {
|
|
$updates['makerworld_collection_id'] = $collection->id;
|
|
}
|
|
if (empty($existingProduct->gallery_images) && ! empty($design['designExtension']['design_pictures'])) {
|
|
$formatted = $this->formatDesignForImport($design);
|
|
$updates['gallery_images'] = $formatted['gallery_images'];
|
|
}
|
|
if (empty($existingProduct->print_info)) {
|
|
$formatted = $this->formatDesignForImport($design);
|
|
$updates['print_info'] = $formatted['print_info'];
|
|
}
|
|
if (! empty($updates)) {
|
|
$existingProduct->update($updates);
|
|
}
|
|
$updated++;
|
|
} else {
|
|
// Fetch full details if necessary to get all high-res photos and instances
|
|
$fullDetails = $this->getDesignDetails($designId) ?? $design;
|
|
|
|
$this->importDesign(
|
|
designData: $fullDetails,
|
|
categoryId: null,
|
|
customPrice: null,
|
|
customName: null,
|
|
isActive: false,
|
|
status: 'draft',
|
|
collectionId: $collection->id
|
|
);
|
|
$imported++;
|
|
}
|
|
}
|
|
|
|
$collection->update([
|
|
'items_count' => count($designs),
|
|
'last_synced_at' => now(),
|
|
'last_sync_status' => 'success',
|
|
'last_sync_message' => "{$imported} nieuwe modellen geïmporteerd als concept, {$updated} bijgewerkt.",
|
|
]);
|
|
|
|
return [
|
|
'imported' => $imported,
|
|
'updated' => $updated,
|
|
'total' => count($designs),
|
|
'error' => null,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error("Sync collection {$collection->id} ({$collection->makerworld_collection_id}) error: ".$e->getMessage());
|
|
|
|
$collection->update([
|
|
'last_synced_at' => now(),
|
|
'last_sync_status' => 'error',
|
|
'last_sync_message' => 'Fout bij synchroniseren: '.$e->getMessage(),
|
|
]);
|
|
|
|
return [
|
|
'imported' => 0,
|
|
'updated' => 0,
|
|
'total' => 0,
|
|
'error' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Basic sanitization of MakerWorld HTML descriptions.
|
|
*/
|
|
protected function sanitizeHtml(string $html): string
|
|
{
|
|
$allowed = '<p><br><strong><b><em><i><u><ul><ol><li><h3><h4><h5><blockquote>';
|
|
|
|
return strip_tags($html, $allowed);
|
|
}
|
|
}
|