create-nevela 0.1.5 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/project.mjs +11 -0
- package/template/laravel/composer.json +61 -59
- package/template/laravel/config/nevela.php +70 -0
- package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
- package/template/laravel/src/Console/GenerateCommand.php +25 -2
- package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
- package/template/laravel/src/Console/SeedCommand.php +186 -124
- package/template/laravel/src/Console/UpdateCommand.php +64 -3
- package/template/laravel/src/Generator/ResourceGenerator.php +151 -19
- package/template/laravel/src/Http/ResourceRequest.php +4 -1
- package/template/laravel/src/Http/UploadController.php +147 -0
- package/template/laravel/src/Media/FileTypes.php +97 -0
- package/template/laravel/src/Media/ImageOptimizer.php +252 -0
- package/template/laravel/src/Media/Profile.php +94 -0
- package/template/laravel/src/Media/UploadRejected.php +8 -0
- package/template/laravel/src/Media/Uploads.php +325 -0
- package/template/laravel/src/Models/Upload.php +46 -0
- package/template/laravel/src/Nevela.php +20 -1
- package/template/laravel/src/NevelaServiceProvider.php +24 -0
- package/template/laravel/src/Rules/UploadKey.php +27 -0
- package/template/laravel/src/Support/Fake.php +194 -171
- package/template/laravel/src/Support/Field.php +79 -10
- package/template/laravel/src/Support/TemplateSource.php +1 -1
- package/template/web/app/api/[...path]/route.ts +87 -0
- package/template/web/app/dashboard/actions.ts +26 -9
- package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
- package/template/web/components/dashboard/fields/file-field.tsx +25 -31
- package/template/web/lib/files.ts +21 -0
- package/template/web/package.json +1 -1
- package/template.mjs +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Models;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
6
|
+
use Illuminate\Database\Eloquent\Model;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One uploaded file. A record's file field holds the key; this row holds everything
|
|
10
|
+
* else about it.
|
|
11
|
+
*
|
|
12
|
+
* @property string $key
|
|
13
|
+
* @property string $disk
|
|
14
|
+
* @property string $resource
|
|
15
|
+
* @property string $field
|
|
16
|
+
* @property string $name
|
|
17
|
+
* @property string $mime
|
|
18
|
+
* @property int $size
|
|
19
|
+
* @property int|null $width
|
|
20
|
+
* @property int|null $height
|
|
21
|
+
* @property array<string, array{key: string, width: int, height: int, size: int}>|null $renditions
|
|
22
|
+
* @property string|null $profile
|
|
23
|
+
* @property bool $optimised
|
|
24
|
+
* @property string|null $original_key
|
|
25
|
+
* @property int|null $original_size
|
|
26
|
+
*/
|
|
27
|
+
class Upload extends Model
|
|
28
|
+
{
|
|
29
|
+
use HasUuids;
|
|
30
|
+
|
|
31
|
+
protected $table = 'nevela_uploads';
|
|
32
|
+
|
|
33
|
+
protected $guarded = [];
|
|
34
|
+
|
|
35
|
+
protected function casts(): array
|
|
36
|
+
{
|
|
37
|
+
return [
|
|
38
|
+
'size' => 'integer',
|
|
39
|
+
'width' => 'integer',
|
|
40
|
+
'height' => 'integer',
|
|
41
|
+
'renditions' => 'array',
|
|
42
|
+
'optimised' => 'boolean',
|
|
43
|
+
'original_size' => 'integer',
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder;
|
|
|
6
6
|
use Illuminate\Http\JsonResponse;
|
|
7
7
|
use Illuminate\Http\Request;
|
|
8
8
|
use InvalidArgumentException;
|
|
9
|
+
use Nevela\Laravel\Media\Uploads;
|
|
9
10
|
use Nevela\Laravel\Support\Descriptor;
|
|
10
11
|
use Nevela\Laravel\Support\ListQuery;
|
|
11
12
|
use Nevela\Laravel\Support\Naming;
|
|
@@ -17,7 +18,7 @@ use Nevela\Laravel\Support\Naming;
|
|
|
17
18
|
final class Nevela
|
|
18
19
|
{
|
|
19
20
|
/** Written by `pnpm release`; the same number as the repository's git tag. */
|
|
20
|
-
public const VERSION = '0.1
|
|
21
|
+
public const VERSION = '0.2.1';
|
|
21
22
|
|
|
22
23
|
/** @var array<string, Descriptor> */
|
|
23
24
|
private static array $descriptors = [];
|
|
@@ -101,6 +102,13 @@ final class Nevela
|
|
|
101
102
|
|
|
102
103
|
$page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
|
|
103
104
|
|
|
105
|
+
// One query for every file on the page, instead of one per record.
|
|
106
|
+
foreach ($resource->fields as $field) {
|
|
107
|
+
if ($field->kind === 'file') {
|
|
108
|
+
Uploads::preload($page->getCollection()->pluck($field->column()));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
104
112
|
return response()->json([
|
|
105
113
|
'data' => $resourceClass::collection($page->getCollection())->resolve($request),
|
|
106
114
|
'meta' => [
|
|
@@ -151,6 +159,17 @@ final class Nevela
|
|
|
151
159
|
]);
|
|
152
160
|
}
|
|
153
161
|
|
|
162
|
+
/**
|
|
163
|
+
* What a client needs to show a stored file: its address, size and dimensions, and
|
|
164
|
+
* each rendition's. Generated API resources send it beside the key, as `<field>File`.
|
|
165
|
+
*
|
|
166
|
+
* @return array<string, mixed>|null
|
|
167
|
+
*/
|
|
168
|
+
public static function file(?string $key): ?array
|
|
169
|
+
{
|
|
170
|
+
return Uploads::ref($key);
|
|
171
|
+
}
|
|
172
|
+
|
|
154
173
|
/** Whether a request is one Nevela answers (so errors use Flare's shape). */
|
|
155
174
|
public static function handles(Request $request): bool
|
|
156
175
|
{
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
namespace Nevela\Laravel;
|
|
4
4
|
|
|
5
5
|
use Illuminate\Contracts\Debug\ExceptionHandler;
|
|
6
|
+
use Illuminate\Foundation\Console\ServeCommand;
|
|
6
7
|
use Illuminate\Support\Facades\Route;
|
|
7
8
|
use Illuminate\Support\ServiceProvider;
|
|
8
9
|
use Nevela\Laravel\Console\DevCommand;
|
|
@@ -16,6 +17,8 @@ use Nevela\Laravel\Console\UserCommand;
|
|
|
16
17
|
use Nevela\Laravel\Console\VersionCommand;
|
|
17
18
|
use Nevela\Laravel\Http\FlareErrors;
|
|
18
19
|
use Nevela\Laravel\Http\TokenController;
|
|
20
|
+
use Nevela\Laravel\Http\UploadController;
|
|
21
|
+
use Nevela\Laravel\Media\Uploads;
|
|
19
22
|
|
|
20
23
|
final class NevelaServiceProvider extends ServiceProvider
|
|
21
24
|
{
|
|
@@ -26,7 +29,17 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
26
29
|
|
|
27
30
|
public function boot(): void
|
|
28
31
|
{
|
|
32
|
+
// The table that records uploads. It arrives with `php artisan migrate`.
|
|
33
|
+
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
|
|
34
|
+
|
|
29
35
|
if ($this->app->runningInConsole()) {
|
|
36
|
+
// `php artisan serve` starts PHP with a short list of environment variables, and
|
|
37
|
+
// the temporary folder isn't on it. On Windows PHP then has nowhere to buffer a
|
|
38
|
+
// request body, and any upload over 16 KB fails. Passing these through fixes it.
|
|
39
|
+
if (class_exists(ServeCommand::class)) {
|
|
40
|
+
ServeCommand::$passthroughVariables = array_values(array_unique([...ServeCommand::$passthroughVariables, 'TEMP', 'TMP', 'TMPDIR']));
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
$this->publishes([__DIR__.'/../config/nevela.php' => config_path('nevela.php')], 'nevela-config');
|
|
31
44
|
$this->commands([
|
|
32
45
|
MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
|
|
@@ -66,7 +79,18 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
66
79
|
'app' => Nevela::fingerprint(),
|
|
67
80
|
]))->name('nevela.ping');
|
|
68
81
|
|
|
82
|
+
// Stored files. Open, like any image on a website: the key is the secret.
|
|
83
|
+
Route::prefix($prefix)->middleware('api')->get('_nevela/files/{path}', [UploadController::class, 'show'])
|
|
84
|
+
->where('path', '.*')->name('nevela.files.show');
|
|
85
|
+
|
|
69
86
|
Route::prefix($prefix)->middleware(config('nevela.middleware', ['api', 'auth:sanctum']))->name('nevela.')->group(function () {
|
|
87
|
+
Route::put('_nevela/uploads/{resource}/{field}', [UploadController::class, 'store'])->name('uploads.store');
|
|
88
|
+
|
|
89
|
+
// The image profiles, for a client that wants to know which renditions exist.
|
|
90
|
+
Route::get('_nevela/profiles', fn () => response()->json([
|
|
91
|
+
'data' => array_map(fn ($name) => (array) Uploads::profile($name), array_keys((array) config('nevela.uploads.profiles', ['default' => []]))),
|
|
92
|
+
]))->name('profiles');
|
|
93
|
+
|
|
70
94
|
// The descriptors, so the web app (and tooling) can check it matches the API.
|
|
71
95
|
Route::get('_nevela/resources', fn () => response()->json([
|
|
72
96
|
'data' => array_map(fn ($d) => $d->toArray(), Nevela::all()),
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Rules;
|
|
4
|
+
|
|
5
|
+
use Closure;
|
|
6
|
+
use Illuminate\Contracts\Validation\ValidationRule;
|
|
7
|
+
use Nevela\Laravel\Models\Upload;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A file field's value has to be the key of something uploaded to that same field.
|
|
11
|
+
* Without this, anyone who can edit a record could point it at another resource's file.
|
|
12
|
+
*/
|
|
13
|
+
final class UploadKey implements ValidationRule
|
|
14
|
+
{
|
|
15
|
+
public function __construct(private readonly string $resource, private readonly string $field) {}
|
|
16
|
+
|
|
17
|
+
public function validate(string $attribute, mixed $value, Closure $fail): void
|
|
18
|
+
{
|
|
19
|
+
if (! is_string($value) || $value === '') {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
$known = Upload::query()->where('key', $value)->where('resource', $this->resource)->where('field', $this->field)->exists();
|
|
23
|
+
if (! $known) {
|
|
24
|
+
$fail('Upload the file again: this one was not uploaded to this field.');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -1,171 +1,194 @@
|
|
|
1
|
-
<?php
|
|
2
|
-
|
|
3
|
-
namespace Nevela\Laravel\Support;
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Plausible rows for a descriptor, for `php artisan nevela:seed`.
|
|
7
|
-
*
|
|
8
|
-
* Pure PHP with no Faker: Laravel apps only install Faker for development, and a seed
|
|
9
|
-
* that fills a staging database shouldn't depend on it. Values follow the same rules the
|
|
10
|
-
* generated form request enforces (lengths, enum options, 0–100 percents, Y-m-d dates),
|
|
11
|
-
* and a field's name is used as a hint, so `email`, `city` or `sku` look like what they are.
|
|
12
|
-
*/
|
|
13
|
-
final class Fake
|
|
14
|
-
{
|
|
15
|
-
private const FIRST = ['Ada', 'Grace', 'Alan', 'Linus', 'Amara', 'Kofi', 'Mei', 'Ravi', 'Sofia', 'Omar', 'Nia', 'Lucas', 'Aisha', 'Mateo', 'Yuki', 'Zainab', 'Noah', 'Priya', 'Elena', 'Kwame'];
|
|
16
|
-
|
|
17
|
-
private const LAST = ['Okafor', 'Nakamura', 'Silva', 'Mukasa', 'Haddad', 'Novak', 'Mensah', 'Patel', 'Garcia', 'Kim', 'Otieno', 'Rossi', 'Dubois', 'Khan', 'Andersson', 'Nkosi', 'Tanaka', 'Moreno', 'Ivanov', 'Abebe'];
|
|
18
|
-
|
|
19
|
-
private const ADJECTIVES = ['Compact', 'Classic', 'Urban', 'Bright', 'Quiet', 'Rapid', 'Solid', 'Smart', 'Fresh', 'Prime', 'Royal', 'Swift', 'Amber', 'Coastal', 'Nordic', 'Golden'];
|
|
20
|
-
|
|
21
|
-
private const NOUNS = ['Lamp', 'Chair', 'Kettle', 'Backpack', 'Notebook', 'Speaker', 'Bottle', 'Desk', 'Jacket', 'Camera', 'Grinder', 'Planter', 'Monitor', 'Keyboard', 'Blanket', 'Mug'];
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
private const
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
private const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
$field->kind === '
|
|
55
|
-
$field->kind === '
|
|
56
|
-
$field->kind === '
|
|
57
|
-
$field->kind === '
|
|
58
|
-
$field->format === '
|
|
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
|
-
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Plausible rows for a descriptor, for `php artisan nevela:seed`.
|
|
7
|
+
*
|
|
8
|
+
* Pure PHP with no Faker: Laravel apps only install Faker for development, and a seed
|
|
9
|
+
* that fills a staging database shouldn't depend on it. Values follow the same rules the
|
|
10
|
+
* generated form request enforces (lengths, enum options, 0–100 percents, Y-m-d dates),
|
|
11
|
+
* and a field's name is used as a hint, so `email`, `city` or `sku` look like what they are.
|
|
12
|
+
*/
|
|
13
|
+
final class Fake
|
|
14
|
+
{
|
|
15
|
+
private const FIRST = ['Ada', 'Grace', 'Alan', 'Linus', 'Amara', 'Kofi', 'Mei', 'Ravi', 'Sofia', 'Omar', 'Nia', 'Lucas', 'Aisha', 'Mateo', 'Yuki', 'Zainab', 'Noah', 'Priya', 'Elena', 'Kwame'];
|
|
16
|
+
|
|
17
|
+
private const LAST = ['Okafor', 'Nakamura', 'Silva', 'Mukasa', 'Haddad', 'Novak', 'Mensah', 'Patel', 'Garcia', 'Kim', 'Otieno', 'Rossi', 'Dubois', 'Khan', 'Andersson', 'Nkosi', 'Tanaka', 'Moreno', 'Ivanov', 'Abebe'];
|
|
18
|
+
|
|
19
|
+
private const ADJECTIVES = ['Compact', 'Classic', 'Urban', 'Bright', 'Quiet', 'Rapid', 'Solid', 'Smart', 'Fresh', 'Prime', 'Royal', 'Swift', 'Amber', 'Coastal', 'Nordic', 'Golden'];
|
|
20
|
+
|
|
21
|
+
private const NOUNS = ['Lamp', 'Chair', 'Kettle', 'Backpack', 'Notebook', 'Speaker', 'Bottle', 'Desk', 'Jacket', 'Camera', 'Grinder', 'Planter', 'Monitor', 'Keyboard', 'Blanket', 'Mug'];
|
|
22
|
+
|
|
23
|
+
/** What a shop files things under: names for a Category, Collection, Department or Tag. */
|
|
24
|
+
private const GROUPS = ['Kitchen', 'Outdoor', 'Office', 'Lighting', 'Audio', 'Travel', 'Home', 'Garden', 'Fitness', 'Accessories', 'Stationery', 'Bedroom', 'Toys', 'Tools', 'Beauty', 'Pets'];
|
|
25
|
+
|
|
26
|
+
private const COMPANIES = ['Acme', 'Globex', 'Initech', 'Northwind', 'Brightline', 'Vandelay', 'Umbrella', 'Hooli', 'Stark', 'Wayne'];
|
|
27
|
+
|
|
28
|
+
private const CITIES = ['Kampala', 'Nairobi', 'Lagos', 'Accra', 'London', 'Berlin', 'Toronto', 'Tokyo', 'Lisbon', 'Austin', 'Mumbai', 'Sydney'];
|
|
29
|
+
|
|
30
|
+
private const COUNTRIES = ['Uganda', 'Kenya', 'Nigeria', 'Ghana', 'United Kingdom', 'Germany', 'Canada', 'Japan', 'Portugal', 'United States', 'India', 'Australia'];
|
|
31
|
+
|
|
32
|
+
private const WORDS = ['reliable', 'handmade', 'everyday', 'lightweight', 'durable', 'refined', 'practical', 'seasonal', 'limited', 'popular', 'tested', 'trusted', 'simple', 'modern', 'original'];
|
|
33
|
+
|
|
34
|
+
/** 2000-01-01 UTC, where unique dates start counting from. */
|
|
35
|
+
private const EPOCH = 946684800;
|
|
36
|
+
|
|
37
|
+
/** A century of distinct days. */
|
|
38
|
+
private const UNIQUE_DAYS = 36500;
|
|
39
|
+
|
|
40
|
+
private const COLORS = 0x1000000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How many distinct values a unique field can hold, or null when it's as good as unlimited.
|
|
44
|
+
* A unique enum of three options can only ever fill three rows; the seed command checks
|
|
45
|
+
* this before it starts rather than failing on the fourth insert.
|
|
46
|
+
*/
|
|
47
|
+
public static function capacity(Field $field): ?int
|
|
48
|
+
{
|
|
49
|
+
if (! $field->unique) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return match (true) {
|
|
54
|
+
$field->kind === 'enum' => count($field->options),
|
|
55
|
+
$field->kind === 'boolean' => 2,
|
|
56
|
+
$field->kind === 'int' && $field->format === 'percent' => 101,
|
|
57
|
+
$field->kind === 'int' && $field->format === 'rating' => 5,
|
|
58
|
+
$field->kind === 'float' && $field->format === 'percent' => 1001,
|
|
59
|
+
$field->kind === 'float' && $field->format === 'rating' => 41,
|
|
60
|
+
$field->kind === 'date' => self::UNIQUE_DAYS,
|
|
61
|
+
$field->format === 'color' => self::COLORS,
|
|
62
|
+
default => null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One row, keyed by column name (without id or timestamps).
|
|
68
|
+
*
|
|
69
|
+
* @param int $number A number no other seeded row of this table shares: what keeps unique fields unique.
|
|
70
|
+
* @param array<string, list<string>> $pools Values to draw from, by field name: the ids a belongsTo may point at, the keys of files that exist.
|
|
71
|
+
* @return array<string, string|int|float|bool|null>
|
|
72
|
+
*/
|
|
73
|
+
public static function row(Descriptor $resource, int $number, array $pools = []): array
|
|
74
|
+
{
|
|
75
|
+
$first = self::pick(self::FIRST);
|
|
76
|
+
$last = self::pick(self::LAST);
|
|
77
|
+
$isPerson = isset($resource->fields['email']) || isset($resource->fields['firstName']);
|
|
78
|
+
$isGroup = (bool) preg_match('/(Category|Collection|Department|Tag|Genre|Brand)$/', $resource->name);
|
|
79
|
+
|
|
80
|
+
$row = [];
|
|
81
|
+
foreach ($resource->fields as $field) {
|
|
82
|
+
// Optional fields are left empty now and then, as real data is.
|
|
83
|
+
if (! $field->required && ! $field->unique && mt_rand(1, 5) === 1) {
|
|
84
|
+
$row[$field->column()] = null;
|
|
85
|
+
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// A relation or a file can't be invented: it has to be one that exists.
|
|
89
|
+
if (in_array($field->kind, ['belongsTo', 'file'], true)) {
|
|
90
|
+
$pool = $pools[$field->name] ?? [];
|
|
91
|
+
$row[$field->column()] = $pool === [] ? null : self::pick($pool);
|
|
92
|
+
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
$row[$field->column()] = self::value($field, $number, $first, $last, $isPerson);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A category is called Kitchen, not Compact Lamp, and its slug follows its name.
|
|
99
|
+
$name = $resource->fields['name'] ?? null;
|
|
100
|
+
if ($isGroup && $name !== null && $name->kind === 'string' && $name->format === null) {
|
|
101
|
+
$group = self::GROUPS[($number - 1) % count(self::GROUPS)];
|
|
102
|
+
$row['name'] = $name->unique ? "{$group} {$number}" : $group;
|
|
103
|
+
$slug = $resource->fields['slug'] ?? null;
|
|
104
|
+
if ($slug !== null && $slug->format === 'slug' && isset($row['slug'])) {
|
|
105
|
+
$row['slug'] = strtolower($group).($slug->unique ? "-{$number}" : '');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return $row;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private static function value(Field $field, int $number, string $first, string $last, bool $isPerson): string|int|float|bool
|
|
113
|
+
{
|
|
114
|
+
return match ($field->kind) {
|
|
115
|
+
// A unique field walks its range by number instead of drawing at random; capacity() says how far that goes.
|
|
116
|
+
'enum' => $field->unique ? $field->options[$number % count($field->options)] : self::pick($field->options),
|
|
117
|
+
'boolean' => $field->unique ? $number % 2 === 0 : mt_rand(1, 10) > 3,
|
|
118
|
+
'int' => match (true) {
|
|
119
|
+
$field->format === 'percent' => $field->unique ? $number % 101 : mt_rand(0, 100),
|
|
120
|
+
$field->format === 'rating' => $field->unique ? 1 + $number % 5 : mt_rand(1, 5),
|
|
121
|
+
$field->unique => $number,
|
|
122
|
+
default => mt_rand(0, 500),
|
|
123
|
+
},
|
|
124
|
+
'float' => match (true) {
|
|
125
|
+
$field->format === 'percent' => round(($field->unique ? $number % 1001 : mt_rand(0, 1000)) / 10, 1),
|
|
126
|
+
$field->format === 'rating' => round(($field->unique ? 10 + $number % 41 : mt_rand(10, 50)) / 10, 1),
|
|
127
|
+
$field->unique => round($number + mt_rand(0, 99) / 100, 2),
|
|
128
|
+
default => round(mt_rand(199, 49999) / 100, 2),
|
|
129
|
+
},
|
|
130
|
+
'date' => $field->unique
|
|
131
|
+
? gmdate('Y-m-d', self::EPOCH + ($number % self::UNIQUE_DAYS) * 86400)
|
|
132
|
+
: date('Y-m-d', time() - mt_rand(-30, 365) * 86400),
|
|
133
|
+
'datetime' => $field->unique
|
|
134
|
+
? gmdate('Y-m-d H:i:s', self::EPOCH + $number)
|
|
135
|
+
: date('Y-m-d H:i:s', time() - mt_rand(0, 365 * 86400)),
|
|
136
|
+
'text' => self::sentence(mt_rand(8, 24)).($field->unique ? " {$number}" : ''),
|
|
137
|
+
default => self::string($field, $number, $first, $last, $isPerson),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private static function string(Field $field, int $number, string $first, string $last, bool $isPerson): string
|
|
142
|
+
{
|
|
143
|
+
$name = strtolower($field->name);
|
|
144
|
+
|
|
145
|
+
$value = match (true) {
|
|
146
|
+
// Always numbered: two people called Ada Okafor must not share an address.
|
|
147
|
+
$field->format === 'email' || str_contains($name, 'email') => strtolower("{$first}.{$last}{$number}@example.com"),
|
|
148
|
+
$field->format === 'url' || str_contains($name, 'website') || str_ends_with($name, 'url') => 'https://example.com/'.strtolower(self::pick(self::NOUNS)).($field->unique ? "-{$number}" : ''),
|
|
149
|
+
$field->format === 'tel' || str_contains($name, 'phone') => $field->unique
|
|
150
|
+
? '+256 7'.str_pad((string) $number, 10, '0', STR_PAD_LEFT)
|
|
151
|
+
: sprintf('+256 7%02d %03d %03d', mt_rand(0, 99), mt_rand(0, 999), mt_rand(0, 999)),
|
|
152
|
+
$field->format === 'slug' => strtolower(self::pick(self::ADJECTIVES).'-'.self::pick(self::NOUNS)).($field->unique ? "-{$number}" : ''),
|
|
153
|
+
$field->format === 'color' => sprintf('#%06x', $field->unique ? $number % self::COLORS : mt_rand(0, 0xFFFFFF)),
|
|
154
|
+
in_array($name, ['sku', 'code', 'reference', 'ref', 'number'], true) => strtoupper(substr(self::pick(self::NOUNS), 0, 3)).'-'.str_pad((string) $number, 6, '0', STR_PAD_LEFT),
|
|
155
|
+
$name === 'firstname' => $first,
|
|
156
|
+
$name === 'lastname' => $last,
|
|
157
|
+
in_array($name, ['name', 'fullname'], true) && $isPerson => "{$first} {$last}",
|
|
158
|
+
str_contains($name, 'company') || str_contains($name, 'organisation') || str_contains($name, 'organization') => self::pick(self::COMPANIES).' '.self::pick(['Ltd', 'Inc', 'Group', 'Labs']),
|
|
159
|
+
str_contains($name, 'city') => self::pick(self::CITIES),
|
|
160
|
+
str_contains($name, 'country') => self::pick(self::COUNTRIES),
|
|
161
|
+
in_array($name, ['name', 'title', 'label'], true) => self::pick(self::ADJECTIVES).' '.self::pick(self::NOUNS),
|
|
162
|
+
default => ucfirst(self::sentence(mt_rand(2, 4), false)),
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// Emails, URLs, slugs, codes and phone numbers end in the number already, and a colour
|
|
166
|
+
// is derived from it; everything else gets it appended.
|
|
167
|
+
if ($field->unique && $field->format !== 'color' && ! preg_match('/'.$number.'(@|$)/', $value)) {
|
|
168
|
+
$value .= " {$number}";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return $value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private static function sentence(int $words, bool $stop = true): string
|
|
175
|
+
{
|
|
176
|
+
$picked = [];
|
|
177
|
+
for ($i = 0; $i < $words; $i++) {
|
|
178
|
+
$picked[] = self::pick(self::WORDS);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return ucfirst(implode(' ', $picked)).($stop ? '.' : '');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @template T
|
|
186
|
+
*
|
|
187
|
+
* @param list<T> $from
|
|
188
|
+
* @return T
|
|
189
|
+
*/
|
|
190
|
+
private static function pick(array $from): mixed
|
|
191
|
+
{
|
|
192
|
+
return $from[mt_rand(0, count($from) - 1)];
|
|
193
|
+
}
|
|
194
|
+
}
|