This repository has been archived by the owner on Mar 20, 2023. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 16
/
Asset.php
688 lines (617 loc) · 23.2 KB
/
Asset.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
<?php
declare(strict_types=1);
namespace App;
use GrahamCampbell\Markdown\Facades\Markdown;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
use Lorisleiva\LaravelSearchString\Concerns\SearchString;
/**
* An asset published by an user (called "author" in this case).
*
* @property int $asset_id The asset's unique ID.
* @property string $title The asset's title.
* @property ?string $blurb The asset's blurb (short description).
* @property int $category_id The asset's category ID (see the `CATEGORY_*` constants).
* @property-read string $category The asset's category as an human-readable name.
* @property-read string $category_icon The asset's category Fork Awesome icon class.
* @property string $cost The asset's license as an SPDX identifier.
* @property-read string $license_name The asset's license as human-readable name.
* @property int $support_level_id The asset's support level (see the `SUPPORT_LEVEL_*` constants).
* @property-read string $support_level The asset's support level as an human-readable name.
* @property string $description The asset's full description in Markdown format.
* @property string $html_description The asset's full description in HTML format (generated from the Markdown source).
* @property array $tags The asset's tags.
* @property string $browse_url The asset's browsable repository URL.
* @property string $issues_url The asset's issue reporting URL (will be inferred from `$browse_url` if empty).
* @property ?string $changelog_url The asset's changelog URL.
* @property ?string $donate_url The asset's donate URL.
* @property string $icon_url The asset's icon URL (will be inferred from `$browse_url` if empty).
* @property-read string $download_url The asset's latest version download URL (for compatibility with the existing asset library API).
* @property-read string $version_string The asset's latest version's supported Godot minor version (e.g. "0.4.2", for compatibility with the existing asset library API).
* @property string $godot_version The asset's latest version's human-readable identifier (e.g. "3.2", for compatibility with the existing asset library API).
* @property \Illuminate\Support\Carbon $created_at The asset's creation date.
* @property \Illuminate\Support\Carbon $modify_date The asset's last modification date.
* @property bool $is_published If `false`, the asset can only be viewed by its author and won't appear in the list and search results.
* @property bool $is_archived If `true`, the asset can no longer receive reviews.
* @property int $score The score calculated from reviews (+1 for positive, -1 for negative).
* @property-read string $score_color The Tailwind CSS classes used to color the asset's score in templates.
* @property int $author_id The asset's author ID.
*/
class Asset extends Model
{
use HasFactory;
use SearchString;
/**
* The key used to store the last modification date and time.
* This value has been changed from the default for compatibility with the
* existing asset library API.
*/
public const UPDATED_AT = 'modify_date';
/**
* The number of assets per page to display by default.
*/
public const ASSETS_PER_PAGE = 40;
/**
* The maximum number of tags an asset may have.
*/
public const MAX_TAGS = 15;
/**
* The support level voluntarily set by users who are submitting assets
* in "testing" version (can be unstable).
* Will be distinguished on the Web interface.
*/
public const SUPPORT_LEVEL_TESTING = 0;
/**
* The default support level for newly submitted assets.
*/
public const SUPPORT_LEVEL_COMMUNITY = 1;
/**
* The support level for assets submitted on behalf of the Godot project.
* Will be distinguished on the Web interface.
*/
public const SUPPORT_LEVEL_OFFICIAL = 2;
public const SUPPORT_LEVEL_MAX = 3;
public const CATEGORY_2D_TOOLS = 0;
public const CATEGORY_3D_TOOLS = 1;
public const CATEGORY_SHADERS = 2;
public const CATEGORY_MATERIALS = 3;
public const CATEGORY_TOOLS = 4;
public const CATEGORY_SCRIPTS = 5;
public const CATEGORY_MISC = 6;
public const CATEGORY_TEMPLATES = 7;
public const CATEGORY_PROJECTS = 8;
public const CATEGORY_DEMOS = 9;
public const CATEGORY_MAX = 10;
/**
* Assets that are part of the Add-ons category type will be displayed
* in the editor's AssetLib tab.
*/
public const CATEGORY_TYPE_ADDONS = 0;
/**
* Assets that are part of the Projects category type will be displayed
* in the Project Manager's Templates tab.
*/
public const CATEGORY_TYPE_PROJECTS = 1;
/**
* The mapping of the available licenses' SPDX identifiers with their human-readable names.
* Should be kept in alphabetical order.
*/
public const LICENSES = [
'Apache-2.0' => 'Apache 2',
'BSD-2-Clause' => 'BSD 2-Clause',
'BSD-3-Clause' => 'BSD 3-Clause',
'CC0-1.0' => 'CC0 1.0 Universal',
'CC-BY-3.0' => 'CC BY 3.0 Unported',
'CC-BY-4.0' => 'CC BY 4.0 International',
'CC-BY-SA-3.0' => 'CC BY-SA 3.0 Unported',
'CC-BY-SA-4.0' => 'CC BY-SA 4.0 International',
'LGPL-2.1-only' => 'LGPLv2.1 only',
'LGPL-2.1-or-later' => 'LGPLv2.1 or later',
'LGPL-3.0-only' => 'LGPLv3 only',
'LGPL-3.0-or-later' => 'LGPLv3 or later',
'GPL-2.0-only' => 'GPLv2 only',
'GPL-2.0-or-later' => 'GPLv2 or later',
'GPL-3.0-only' => 'GPLv3 only',
'GPL-3.0-or-later' => 'GPLv3 or later',
'MIT' => 'MIT',
'MPL-2.0' => 'MPLv2',
];
/**
* The primary key associated with the table.
* This value has been changed from the default for compatibility with the
* existing asset library API.
*
* @var string
*/
protected $primaryKey = 'asset_id';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'title',
'blurb',
'description',
'tags',
'author_id',
'category_id',
'cost',
'support_level',
'browse_url',
'issues_url',
'changelog_url',
'donate_url',
'icon_url',
'versions',
'previews',
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'support_level_id',
'created_at',
// The Godot editor can't render HTML, no need to send it
'html_description',
'is_published',
];
/**
* The accessors to append to the model's array form.
*
* @var array
*/
protected $appends = [
'category',
'tags',
'download_hash',
'download_url',
'godot_version',
'icon_url',
'support_level',
'version_string',
];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'category_id' => 'integer',
'support_level_id' => 'integer',
'is_published' => 'boolean',
'is_archived' => 'boolean',
];
/**
* The columns that can be searched for using the search string syntax.
*
* @var array
* @see https://github.com/lorisleiva/laravel-search-string#configuring-columns
*/
protected $searchStringColumns = [
'title' => ['searchable' => true],
'blurb' => ['searchable' => true],
'cost' => 'license',
'support_level_id',
'tags' => ['searchable' => true],
'created_at',
'modify_date' => 'updated_at',
'score',
];
/**
* The special keywords allowed in the search string syntax.
*
* @var array<string, bool>
* @see https://github.com/lorisleiva/laravel-search-string#configuring-special-keywords
*/
protected $searchStringKeywords = [
// Don't allow restricting the fields returned as it results in SQL errors
'select' => false,
// Don't allow sorting as it results in SQL errors when using a non-existing field
'order_by' => false,
// Don't allow offsetting as it results in SQL errors if not using `limit` at the same time
'offset' => false,
];
/**
* Get the user that posted the asset.
*/
public function author(): BelongsTo
{
return $this->belongsTo('App\User', 'author_id');
}
/**
* Get the asset's previews.
*/
public function previews(): HasMany
{
return $this->hasMany('App\AssetPreview', 'asset_id');
}
/**
* Get the asset's versions.
*/
public function versions(): HasMany
{
return $this->hasMany('App\AssetVersion', 'asset_id');
}
/**
* Get the asset's reviews (sorted by reverse creation date).
*/
public function reviews(): HasMany
{
return $this->hasMany('App\AssetReview', 'asset_id')->orderBy('created_at', 'desc');
}
/**
* Return the given support level's name.
*/
public static function getSupportLevelName(int $supportLevel): string
{
$supportLevelNames = [
self::SUPPORT_LEVEL_TESTING => 'Testing',
self::SUPPORT_LEVEL_COMMUNITY => 'Community',
self::SUPPORT_LEVEL_OFFICIAL => 'Official',
];
if (array_key_exists($supportLevel, $supportLevelNames)) {
return $supportLevelNames[$supportLevel];
} else {
throw new \Exception("Invalid support level: $supportLevel");
}
}
/**
* Return the given category's name.
*/
public static function getCategoryName(int $category): string
{
$categoryNames = [
self::CATEGORY_2D_TOOLS => '2D Tools',
self::CATEGORY_3D_TOOLS => '3D Tools',
self::CATEGORY_SHADERS => 'Shaders',
self::CATEGORY_MATERIALS => 'Materials',
self::CATEGORY_TOOLS => 'Tools',
self::CATEGORY_SCRIPTS => 'Scripts',
self::CATEGORY_MISC => 'Misc',
self::CATEGORY_TEMPLATES => 'Templates',
self::CATEGORY_PROJECTS => 'Projects',
self::CATEGORY_DEMOS => 'Demos',
];
if (array_key_exists($category, $categoryNames)) {
return $categoryNames[$category];
} else {
throw new \Exception("Invalid category: $category");
}
}
/**
* Return the given category's Fork Awesome icon code.
*/
public static function getCategoryIcon(int $category): string
{
$categoryNames = [
self::CATEGORY_2D_TOOLS => 'fa-picture-o',
self::CATEGORY_3D_TOOLS => 'fa-cube',
self::CATEGORY_SHADERS => 'fa-book',
self::CATEGORY_MATERIALS => 'fa-archive',
self::CATEGORY_TOOLS => 'fa-cogs',
self::CATEGORY_SCRIPTS => 'fa-file-text',
self::CATEGORY_MISC => 'fa-gamepad',
self::CATEGORY_TEMPLATES => 'fa-folder-open',
self::CATEGORY_PROJECTS => 'fa-folder-open',
self::CATEGORY_DEMOS => 'fa-folder-open',
];
if (array_key_exists($category, $categoryNames)) {
return $categoryNames[$category];
} else {
throw new \Exception("Invalid category: $category");
}
}
/**
* Return the list of tags as an array.
*
* @return string[]
*/
public function getTagsAttribute(): array
{
if (! empty($this->getRawOriginal('tags'))) {
return explode(',', $this->getRawOriginal('tags'));
}
return [];
}
/**
* Set the list of tags (which is stored as a comma-separated string in the database).
*
* TODO: Use a custom validator to enforce tag naming rules.
*
* @param string|array $tags The list of tags to assign to the asset
*/
public function setTagsAttribute($tags = null): void
{
if (is_array($tags)) {
$this->attributes['tags'] = implode(',', $tags);
} else {
// Remove empty tags, spaces and replace uppercase characters with lowercase characters
$tagsSanitized = array_filter(
explode(',', strtolower(str_replace(' ', '', $tags ?? ''))),
function ($tag) {
return strlen($tag) >= 1;
}
);
$this->attributes['tags'] = implode(',', $tagsSanitized);
}
}
/**
* Enforces HTTPS for the repository URL.
* This also makes sure the repository URL doesn't end with `.git` or a trailing slash.
*/
public function setBrowseUrlAttribute(string $browseUrl): void
{
$httpsUrl = str_replace('http://', 'https://', $browseUrl);
if (Str::endsWith($httpsUrl, '.git')) {
$this->attributes['browse_url'] = str_replace('.git', '', $httpsUrl);
} else {
$this->attributes['browse_url'] = rtrim($httpsUrl, '/');
}
}
/**
* Placeholder to make the Godot editor accept the download hash but not verify it.
*
* TODO: Consider computing the download hash on a per-version basis and store it.
* However, this may not be a good idea as GitHub ZIP hashes aren't guaranteed
* to be the same over a long period of time.
*/
public function getDownloadHashAttribute(): string
{
return '';
}
/**
* Return the download URL corresponding to the latest version
* (for compatibility with the existing API).
*/
public function getDownloadUrlAttribute(): string
{
return $this->versions->last()->getDownloadUrlAttribute($this->browse_url);
}
/**
* Return the Godot version corresponding to the latest version
* (for compatibility with the existing API).
*/
public function getGodotVersionAttribute(): string
{
return $this->versions->last()->godot_version;
}
/**
* Set the description, render the Markdown and save the rendered description.
* This way, the source Markdown only has to be rendered once
* (instead of being rendered every time a page is displayed).
*/
public function setDescriptionAttribute(string $description): void
{
$this->attributes['description'] = $description;
$this->attributes['html_description'] = Markdown::convertToHtml($description);
}
/**
* Return the icon URL (will infer a URL if no custom URL is specified by the asset).
*/
public function getIconUrlAttribute(): string
{
// Return the custom icon URL if defined
if ($this->getRawOriginal('icon_url')) {
return $this->getRawOriginal('icon_url');
}
$splitUrl = explode('/', $this->browse_url);
// Slug of the form `user/repository`
$slug = "$splitUrl[3]/$splitUrl[4]";
// Try to infer an icon URL based on the repository host
// (`icon.png` at the repository root)
if ($splitUrl[2] === 'github.com') {
return "https://raw.githubusercontent.com/$slug/master/icon.png";
} elseif ($splitUrl[2] === 'gitlab.com') {
return "https://gitlab.com/$slug/raw/master/icon.png";
} elseif ($splitUrl[2] === 'bitbucket.org') {
return "https://bitbucket.org/$slug/raw/master/icon.png";
}
// Couldn't infer an icon URL
return '';
}
/**
* Enforces HTTPS for the icon URL.
*/
public function setIconUrlAttribute(string $iconUrl = null): void
{
// This field is nullable, so we check for the parameter first
if ($iconUrl) {
$this->attributes['icon_url'] = str_replace('http://', 'https://', $iconUrl);
}
}
/**
* Return the issue reporting URL (will infer a URL if no custom URL is specified by the asset).
*/
public function getIssuesUrlAttribute(): string
{
// GitHub, GitLab and Bitbucket all use an `/issues` suffix
return $this->getRawOriginal('issues_url') ?? "$this->browse_url/issues";
}
/**
* Enforces HTTPS for the issue reporting URL.
*/
public function setIssuesUrlAttribute(string $issuesUrl = null): void
{
// This field is nullable, so we check for the parameter first
if ($issuesUrl) {
$this->attributes['issues_url'] = str_replace('http://', 'https://', $issuesUrl);
}
}
/**
* Return the asset version corresponding to the latest version
* (for compatibility with the existing API).
*/
public function getVersionStringAttribute(): string
{
return $this->versions->last()->version_string;
}
/**
* Non-static variant of `getCategoryName()` (used in serialization).
*/
public function getCategoryAttribute(): string
{
return self::getCategoryName($this->category_id);
}
/**
* Non-static variant of `getCategoryIcon()` (used in templates).
*/
public function getCategoryIconAttribute(): string
{
return self::getCategoryIcon($this->category_id);
}
/**
* Non-static variant of `getSupportLevelName()` (used in serialization).
*/
public function getSupportLevelAttribute(): string
{
return self::getSupportLevelName($this->support_level_id);
}
/**
* Non-static variant of `getLicenseName` (used in templates).
*/
public function getLicenseNameAttribute(): string
{
// Return the SPDX identifier as a fallback
return self::LICENSES[$this->cost] ?? $this->cost;
}
/**
* Return the given category's type.
*/
public static function getCategoryType(int $category): int
{
switch ($category) {
case self::CATEGORY_TEMPLATES:
case self::CATEGORY_PROJECTS:
case self::CATEGORY_DEMOS:
return self::CATEGORY_TYPE_PROJECTS;
default:
if ($category >= 0 && $category < self::CATEGORY_MAX) {
return self::CATEGORY_TYPE_ADDONS;
}
throw new \Exception("Invalid category: $category");
}
}
/**
* Return the Tailwind CSS class used to color the score displayed.
* Higher scores will be displayed in a color with a warmer temperature
* to attract the user's attention.
*
* @see https://tailwindcss.com/docs/text-color/ Tailwind's color classes.
*/
public function getScoreColorAttribute(): string
{
if ($this->score >= 15) {
return 'text-blue-500 dark:text-blue-400';
} elseif ($this->score >= 10) {
return 'text-blue-600 dark:text-blue-300';
} elseif ($this->score >= 5) {
return 'text-blue-700 dark:text-blue-200';
} elseif ($this->score >= -1) {
return 'text-gray-700 dark:text-gray-500';
} else {
return 'text-red-700 dark:text-red-400';
}
}
/**
* Filter and sort the list of assets according to user-specified parameters.
* This function doesn't perform any validation. The data passed must be
* validated first!
*
* TODO: Implement more search filters.
*
* @see App\Http\Requests\ListAssets
*/
public function scopeFilterSearch(Builder $query, array $validated, bool $publishedOnly = true): Collection
{
if ($publishedOnly) {
$query->where('is_published', true);
}
if (isset($validated['type'])) {
// FIXME: Avoid duplicating the category type detection
switch ($validated['type']) {
case 'addon':
$query->whereNotIn(
'category_id',
[self::CATEGORY_TEMPLATES, self::CATEGORY_PROJECTS, self::CATEGORY_DEMOS]
);
break;
case 'project':
$query->whereIn(
'category_id',
[self::CATEGORY_TEMPLATES, self::CATEGORY_PROJECTS, self::CATEGORY_DEMOS]
);
break;
default:
break;
}
}
if (isset($validated['category'])) {
$query->where('category_id', $validated['category']);
}
if (isset($validated['user'])) {
// Prevent the value from being null, assign `-1` if the user isn't found
// (no user ID will match it, as IDs are always positive)
$queryAuthorId = User::where('name', $validated['user'])->first()['id'] ?? -1;
$query->where('author_id', $queryAuthorId);
}
if (isset($validated['filter'])) {
// Search string options are defined in the Asset model
$query->usingSearchString($validated['filter']);
}
$reverse = isset($validated['reverse']);
if (isset($validated['sort'])) {
// Only valid `sort` options affect the sort order in any way
switch ($validated['sort']) {
case 'cost':
$query->orderBy('cost', $reverse ? 'desc' : 'asc');
break;
case 'name':
$query->orderBy('title', $reverse ? 'desc' : 'asc');
break;
case 'rating':
// Having the best ratings first by default makes more sense
$query->orderBy('score', $reverse ? 'asc' : 'desc');
break;
default:
// Also handles `updated`
$query->orderBy('modify_date', $reverse ? 'asc' : 'desc');
break;
}
} else {
// Sort by update date by default
$query->orderBy('modify_date', $reverse ? 'asc' : 'desc');
}
// Query builder methods must be called above, not below
$result = $query->get();
if (isset($validated['godot_version'])) {
// Filtering on a computed property can't be done with the query builder,
// so we have to do it on the returned collection instead.
// See `AssetVersion::GODOT_VERSION_FILTERS` for the list of allowed version filters.
// Only return results compatible with the given Godot version filter.
// Assets whose Godot version is `*` are declared to be compatible with
// any Godot version (this is typically used for non-code assets).
$result = $result->filter(function ($asset) use ($validated) {
return $asset->godot_version === '*'
|| $asset->godot_version === '3.x.x' && Str::startsWith($validated['godot_version'], '3')
|| $asset->godot_version === '4.x.x' && Str::startsWith($validated['godot_version'], '4')
|| $asset->godot_version === $validated['godot_version'].'.x';
});
}
return $result;
}
/**
* Converts the model to a string representation (used for logging purposes).
*/
public function __toString(): string
{
return "\"$this->title\" (#$this->asset_id)";
}
}