1
|
|
|
<?php namespace Cviebrock\EloquentSluggable\Services; |
2
|
|
|
|
3
|
|
|
use Cocur\Slugify\Slugify; |
4
|
|
|
use Illuminate\Database\Eloquent\Model; |
5
|
|
|
use Illuminate\Support\Arr; |
6
|
|
|
use Illuminate\Support\Collection; |
7
|
|
|
|
8
|
|
|
/** |
9
|
|
|
* Class SlugService |
10
|
|
|
* |
11
|
|
|
* @package Cviebrock\EloquentSluggable\Services |
12
|
|
|
*/ |
13
|
|
|
class SlugService |
14
|
|
|
{ |
15
|
|
|
|
16
|
|
|
/** |
17
|
|
|
* @var \Illuminate\Database\Eloquent\Model; |
18
|
|
|
*/ |
19
|
|
|
protected $model; |
20
|
|
|
|
21
|
|
|
/** |
22
|
|
|
* Slug the current model. |
23
|
|
|
* |
24
|
|
|
* @param \Illuminate\Database\Eloquent\Model $model |
25
|
|
|
* @param bool $force |
26
|
|
|
* |
27
|
|
|
* @return bool |
28
|
|
|
*/ |
29
|
|
View Code Duplication |
public function slug(Model $model, bool $force = false): bool |
|
|
|
|
30
|
|
|
{ |
31
|
|
|
$this->setModel($model); |
32
|
|
|
|
33
|
|
|
$attributes = []; |
34
|
|
|
|
35
|
|
|
foreach ($this->model->sluggable() as $attribute => $config) { |
36
|
|
|
if (is_numeric($attribute)) { |
37
|
|
|
$attribute = $config; |
38
|
|
|
$config = $this->getConfiguration(); |
39
|
|
|
} else { |
40
|
|
|
$config = $this->getConfiguration($config); |
41
|
|
|
} |
42
|
|
|
|
43
|
|
|
$slug = $this->buildSlug($attribute, $config, $force); |
44
|
|
|
|
45
|
|
|
if ($slug !== null) { |
46
|
|
|
$this->model->setAttribute($attribute, $slug); |
47
|
|
|
$attributes[] = $attribute; |
48
|
|
|
} |
49
|
|
|
} |
50
|
|
|
|
51
|
|
|
return $this->model->isDirty($attributes); |
52
|
|
|
} |
53
|
|
|
|
54
|
|
|
/** |
55
|
|
|
* Get the sluggable configuration for the current model, |
56
|
|
|
* including default values where not specified. |
57
|
|
|
* |
58
|
|
|
* @param array $overrides |
59
|
|
|
* |
60
|
|
|
* @return array |
61
|
|
|
*/ |
62
|
|
|
public function getConfiguration(array $overrides = []): array |
63
|
|
|
{ |
64
|
|
|
$defaultConfig = config('sluggable', []); |
65
|
|
|
|
66
|
|
|
return array_merge($defaultConfig, $overrides); |
67
|
|
|
} |
68
|
|
|
|
69
|
|
|
/** |
70
|
|
|
* Build the slug for the given attribute of the current model. |
71
|
|
|
* |
72
|
|
|
* @param string $attribute |
73
|
|
|
* @param array $config |
74
|
|
|
* @param bool $force |
75
|
|
|
* |
76
|
|
|
* @return null|string |
77
|
|
|
*/ |
78
|
|
|
public function buildSlug(string $attribute, array $config, bool $force = null) |
79
|
|
|
{ |
80
|
|
|
$slug = null; |
81
|
|
|
|
82
|
|
|
if ($force || $this->needsSlugging($attribute, $config)) { |
83
|
|
|
$sources = $this->getSlugSources($config['source']); |
84
|
|
|
|
85
|
|
|
if (is_array($sources) && !empty($sources)) { |
86
|
|
|
$slugs = []; |
87
|
|
|
|
88
|
|
|
foreach ($sources as $index => $slug) { |
89
|
|
|
if (!$slug && !is_numeric($slug)) { |
90
|
|
|
continue; |
91
|
|
|
} |
92
|
|
|
|
93
|
|
|
// If the source already contains the field separator char, |
94
|
|
|
// we will split the string and generate a slug for each |
95
|
|
|
// substring before concatenating them again using the field |
96
|
|
|
// separator char |
97
|
|
|
if (strpos($slug, $config['field_separator']) !== false) { |
98
|
|
|
$parts = explode($config['field_separator'], $slug); |
99
|
|
|
$slugParts = array_map(function($value) use ($config, $attribute) { |
100
|
|
|
$s = $this->generateSlug($value, $config, $attribute); |
101
|
|
|
$s = $this->validateSlug($s, $config, $attribute); |
102
|
|
|
|
103
|
|
|
return $s; |
104
|
|
|
}, $parts); |
105
|
|
|
|
106
|
|
|
$slug = implode($config['field_separator'], $slugParts); |
107
|
|
|
} else { |
108
|
|
|
$slug = $this->generateSlug($slug, $config, $attribute); |
109
|
|
|
$slug = $this->validateSlug($slug, $config, $attribute); |
110
|
|
|
} |
111
|
|
|
|
112
|
|
|
// We only need to make the last part of the slug unique |
113
|
|
|
if ($index === count($sources) - 1) { |
114
|
|
|
$slug = $this->makeSlugUnique($slug, $config, $attribute); |
115
|
|
|
} |
116
|
|
|
|
117
|
|
|
$slugs[] = $slug; |
118
|
|
|
} |
119
|
|
|
|
120
|
|
|
$slug = implode($config['field_separator'], $slugs); |
121
|
|
|
} |
122
|
|
|
|
123
|
|
|
if($slug === '') { |
124
|
|
|
return null; |
125
|
|
|
} |
126
|
|
|
} |
127
|
|
|
|
128
|
|
|
return $slug; |
129
|
|
|
} |
130
|
|
|
|
131
|
|
|
/** |
132
|
|
|
* Determines whether the model needs slugging. |
133
|
|
|
* |
134
|
|
|
* @param string $attribute |
135
|
|
|
* @param array $config |
136
|
|
|
* |
137
|
|
|
* @return bool |
138
|
|
|
*/ |
139
|
|
View Code Duplication |
protected function needsSlugging(string $attribute, array $config): bool |
|
|
|
|
140
|
|
|
{ |
141
|
|
|
if ( |
142
|
|
|
$config['onUpdate'] === true || |
143
|
|
|
empty($this->model->getAttributeValue($attribute)) |
144
|
|
|
) { |
145
|
|
|
return true; |
146
|
|
|
} |
147
|
|
|
|
148
|
|
|
if ($this->model->isDirty($attribute)) { |
149
|
|
|
return false; |
150
|
|
|
} |
151
|
|
|
|
152
|
|
|
return (!$this->model->exists); |
153
|
|
|
} |
154
|
|
|
|
155
|
|
|
/** |
156
|
|
|
* Get the source strings for the slug. |
157
|
|
|
* |
158
|
|
|
* @param mixed $from |
159
|
|
|
* |
160
|
|
|
* @return array |
161
|
|
|
*/ |
162
|
|
View Code Duplication |
protected function getSlugSources($from): array |
|
|
|
|
163
|
|
|
{ |
164
|
|
|
if (is_null($from)) { |
165
|
|
|
return [$this->model->__toString()]; |
166
|
|
|
} |
167
|
|
|
|
168
|
|
|
$sourceStrings = array_map(function ($key) { |
169
|
|
|
$value = data_get($this->model, $key); |
170
|
|
|
if (is_bool($value)) { |
171
|
|
|
$value = (int) $value; |
172
|
|
|
} |
173
|
|
|
|
174
|
|
|
return $value; |
175
|
|
|
}, (array) $from); |
176
|
|
|
|
177
|
|
|
return $sourceStrings; |
178
|
|
|
} |
179
|
|
|
|
180
|
|
|
/** |
181
|
|
|
* Generate a slug from the given source string. |
182
|
|
|
* |
183
|
|
|
* @param string $source |
184
|
|
|
* @param array $config |
185
|
|
|
* @param string $attribute |
186
|
|
|
* |
187
|
|
|
* @return string |
188
|
|
|
* @throws \UnexpectedValueException |
189
|
|
|
*/ |
190
|
|
View Code Duplication |
protected function generateSlug(string $source, array $config, string $attribute): string |
|
|
|
|
191
|
|
|
{ |
192
|
|
|
$separator = $config['separator']; |
193
|
|
|
$method = $config['method']; |
194
|
|
|
$maxLength = $config['maxLength']; |
195
|
|
|
$maxLengthKeepWords = $config['maxLengthKeepWords']; |
196
|
|
|
|
197
|
|
|
if ($method === null) { |
198
|
|
|
$slugEngine = $this->getSlugEngine($attribute); |
199
|
|
|
$slug = $slugEngine->slugify($source, $separator); |
200
|
|
|
} elseif (is_callable($method)) { |
201
|
|
|
$slug = call_user_func($method, $source, $separator); |
202
|
|
|
} else { |
203
|
|
|
throw new \UnexpectedValueException('Sluggable "method" for ' . get_class($this->model) . ':' . $attribute . ' is not callable nor null.'); |
204
|
|
|
} |
205
|
|
|
|
206
|
|
|
$len = mb_strlen($slug); |
207
|
|
|
if (is_string($slug) && $maxLength && $len > $maxLength) { |
208
|
|
|
$reverseOffset = $maxLength - $len; |
209
|
|
|
$lastSeparatorPos = mb_strrpos($slug, $separator, $reverseOffset); |
210
|
|
|
if ($maxLengthKeepWords && $lastSeparatorPos !== false) { |
211
|
|
|
$slug = mb_substr($slug, 0, $lastSeparatorPos); |
212
|
|
|
} else { |
213
|
|
|
$slug = trim(mb_substr($slug, 0, $maxLength), $separator); |
214
|
|
|
} |
215
|
|
|
} |
216
|
|
|
|
217
|
|
|
return $slug; |
218
|
|
|
} |
219
|
|
|
|
220
|
|
|
/** |
221
|
|
|
* Return a class that has a `slugify()` method, used to convert |
222
|
|
|
* strings into slugs. |
223
|
|
|
* |
224
|
|
|
* @param string $attribute |
225
|
|
|
* |
226
|
|
|
* @return \Cocur\Slugify\Slugify |
227
|
|
|
*/ |
228
|
|
View Code Duplication |
protected function getSlugEngine(string $attribute): Slugify |
|
|
|
|
229
|
|
|
{ |
230
|
|
|
static $slugEngines = []; |
231
|
|
|
|
232
|
|
|
$key = get_class($this->model) . '.' . $attribute; |
233
|
|
|
|
234
|
|
|
if (!array_key_exists($key, $slugEngines)) { |
235
|
|
|
$engine = new Slugify(); |
236
|
|
|
if (method_exists($this->model, 'customizeSlugEngine')) { |
237
|
|
|
$engine = $this->model->customizeSlugEngine($engine, $attribute); |
238
|
|
|
} |
239
|
|
|
|
240
|
|
|
$slugEngines[$key] = $engine; |
241
|
|
|
} |
242
|
|
|
|
243
|
|
|
return $slugEngines[$key]; |
244
|
|
|
} |
245
|
|
|
|
246
|
|
|
/** |
247
|
|
|
* Checks that the given slug is not a reserved word. |
248
|
|
|
* |
249
|
|
|
* @param string $slug |
250
|
|
|
* @param array $config |
251
|
|
|
* @param string $attribute |
252
|
|
|
* |
253
|
|
|
* @return string |
254
|
|
|
* @throws \UnexpectedValueException |
255
|
|
|
*/ |
256
|
|
View Code Duplication |
protected function validateSlug(string $slug, array $config, string $attribute): string |
|
|
|
|
257
|
|
|
{ |
258
|
|
|
$separator = $config['separator']; |
259
|
|
|
$reserved = $config['reserved']; |
260
|
|
|
|
261
|
|
|
if ($reserved === null) { |
262
|
|
|
return $slug; |
263
|
|
|
} |
264
|
|
|
|
265
|
|
|
// check for reserved names |
266
|
|
|
if ($reserved instanceof \Closure) { |
267
|
|
|
$reserved = $reserved($this->model); |
268
|
|
|
} |
269
|
|
|
|
270
|
|
|
if (is_array($reserved)) { |
271
|
|
|
if (in_array($slug, $reserved)) { |
272
|
|
|
$method = $config['uniqueSuffix']; |
273
|
|
|
if ($method === null) { |
274
|
|
|
$suffix = $this->generateSuffix($slug, $separator, collect($reserved)); |
275
|
|
|
} elseif (is_callable($method)) { |
276
|
|
|
$suffix = $method($slug, $separator, collect($reserved)); |
277
|
|
|
} else { |
278
|
|
|
throw new \UnexpectedValueException('Sluggable "uniqueSuffix" for ' . get_class($this->model) . ':' . $attribute . ' is not null, or a closure.'); |
279
|
|
|
} |
280
|
|
|
|
281
|
|
|
return $slug . $separator . $suffix; |
282
|
|
|
} |
283
|
|
|
|
284
|
|
|
return $slug; |
285
|
|
|
} |
286
|
|
|
|
287
|
|
|
throw new \UnexpectedValueException('Sluggable "reserved" for ' . get_class($this->model) . ':' . $attribute . ' is not null, an array, or a closure that returns null/array.'); |
288
|
|
|
} |
289
|
|
|
|
290
|
|
|
/** |
291
|
|
|
* Checks if the slug should be unique, and makes it so if needed. |
292
|
|
|
* |
293
|
|
|
* @param string $slug |
294
|
|
|
* @param array $config |
295
|
|
|
* @param string $attribute |
296
|
|
|
* |
297
|
|
|
* @return string |
298
|
|
|
* @throws \UnexpectedValueException |
299
|
|
|
*/ |
300
|
|
View Code Duplication |
protected function makeSlugUnique(string $slug, array $config, string $attribute): string |
|
|
|
|
301
|
|
|
{ |
302
|
|
|
if (!$config['unique']) { |
303
|
|
|
return $slug; |
304
|
|
|
} |
305
|
|
|
|
306
|
|
|
$separator = $config['separator']; |
307
|
|
|
|
308
|
|
|
// find all models where the slug is like the current one |
309
|
|
|
$list = $this->getExistingSlugs($slug, $attribute, $config); |
310
|
|
|
|
311
|
|
|
// if ... |
312
|
|
|
// a) the list is empty, or |
313
|
|
|
// b) our slug isn't in the list |
314
|
|
|
// ... we are okay |
315
|
|
|
if ( |
316
|
|
|
$list->count() === 0 || |
317
|
|
|
$list->contains($slug) === false |
318
|
|
|
) { |
319
|
|
|
return $slug; |
320
|
|
|
} |
321
|
|
|
|
322
|
|
|
// if our slug is in the list, but |
323
|
|
|
// a) it's for our model, or |
324
|
|
|
// b) it looks like a suffixed version of our slug |
325
|
|
|
// ... we are also okay (use the current slug) |
326
|
|
|
if ($list->has($this->model->getKey())) { |
327
|
|
|
$currentSlug = $list->get($this->model->getKey()); |
328
|
|
|
|
329
|
|
|
if ( |
330
|
|
|
$currentSlug === $slug || |
331
|
|
|
!$slug || strpos($currentSlug, $slug) === 0 |
332
|
|
|
) { |
333
|
|
|
return $currentSlug; |
334
|
|
|
} |
335
|
|
|
} |
336
|
|
|
|
337
|
|
|
$method = $config['uniqueSuffix']; |
338
|
|
|
if ($method === null) { |
339
|
|
|
$suffix = $this->generateSuffix($slug, $separator, $list); |
340
|
|
|
} elseif (is_callable($method)) { |
341
|
|
|
$suffix = $method($slug, $separator, $list); |
342
|
|
|
} else { |
343
|
|
|
throw new \UnexpectedValueException('Sluggable "uniqueSuffix" for ' . get_class($this->model) . ':' . $attribute . ' is not null, or a closure.'); |
344
|
|
|
} |
345
|
|
|
|
346
|
|
|
return $slug . $separator . $suffix; |
347
|
|
|
} |
348
|
|
|
|
349
|
|
|
/** |
350
|
|
|
* Generate a unique suffix for the given slug (and list of existing, "similar" slugs. |
351
|
|
|
* |
352
|
|
|
* @param string $slug |
353
|
|
|
* @param string $separator |
354
|
|
|
* @param \Illuminate\Support\Collection $list |
355
|
|
|
* |
356
|
|
|
* @return string |
357
|
|
|
*/ |
358
|
|
View Code Duplication |
protected function generateSuffix(string $slug, string $separator, Collection $list): string |
|
|
|
|
359
|
|
|
{ |
360
|
|
|
$len = strlen($slug . $separator); |
361
|
|
|
|
362
|
|
|
// If the slug already exists, but belongs to |
363
|
|
|
// our model, return the current suffix. |
364
|
|
|
if ($list->search($slug) === $this->model->getKey()) { |
365
|
|
|
$suffix = explode($separator, $slug); |
366
|
|
|
|
367
|
|
|
return end($suffix); |
368
|
|
|
} |
369
|
|
|
|
370
|
|
|
$list->transform(function ($value, $key) use ($len) { |
|
|
|
|
371
|
|
|
return (int) substr($value, $len); |
372
|
|
|
}); |
373
|
|
|
|
374
|
|
|
// find the highest value and return one greater. |
375
|
|
|
return $list->max() + 1; |
376
|
|
|
} |
377
|
|
|
|
378
|
|
|
/** |
379
|
|
|
* Get all existing slugs that are similar to the given slug. |
380
|
|
|
* |
381
|
|
|
* @param string $slug |
382
|
|
|
* @param string $attribute |
383
|
|
|
* @param array $config |
384
|
|
|
* |
385
|
|
|
* @return \Illuminate\Support\Collection |
386
|
|
|
*/ |
387
|
|
View Code Duplication |
protected function getExistingSlugs(string $slug, string $attribute, array $config): Collection |
|
|
|
|
388
|
|
|
{ |
389
|
|
|
$includeTrashed = $config['includeTrashed']; |
390
|
|
|
|
391
|
|
|
$query = $this->model->newQuery() |
392
|
|
|
->findSimilarSlugs($attribute, $config, $slug); |
393
|
|
|
|
394
|
|
|
// use the model scope to find similar slugs |
395
|
|
|
if (method_exists($this->model, 'scopeWithUniqueSlugConstraints')) { |
396
|
|
|
$query->withUniqueSlugConstraints($this->model, $attribute, $config, $slug); |
397
|
|
|
} |
398
|
|
|
|
399
|
|
|
// include trashed models if required |
400
|
|
|
if ($includeTrashed && $this->usesSoftDeleting()) { |
401
|
|
|
$query->withTrashed(); |
402
|
|
|
} |
403
|
|
|
|
404
|
|
|
// get the list of all matching slugs |
405
|
|
|
$results = $query->select([$attribute, $this->model->getQualifiedKeyName()]) |
406
|
|
|
->get() |
407
|
|
|
->toBase(); |
408
|
|
|
|
409
|
|
|
// key the results and return |
410
|
|
|
return $results->pluck($attribute, $this->model->getKeyName()); |
411
|
|
|
} |
412
|
|
|
|
413
|
|
|
/** |
414
|
|
|
* Does this model use softDeleting? |
415
|
|
|
* |
416
|
|
|
* @return bool |
417
|
|
|
*/ |
418
|
|
|
protected function usesSoftDeleting(): bool |
419
|
|
|
{ |
420
|
|
|
return method_exists($this->model, 'bootSoftDeletes'); |
421
|
|
|
} |
422
|
|
|
|
423
|
|
|
/** |
424
|
|
|
* Generate a unique slug for a given string. |
425
|
|
|
* |
426
|
|
|
* @param \Illuminate\Database\Eloquent\Model|string $model |
427
|
|
|
* @param string $attribute |
428
|
|
|
* @param string $fromString |
429
|
|
|
* @param array|null $config |
430
|
|
|
* |
431
|
|
|
* @return string |
432
|
|
|
* @throws \InvalidArgumentException |
433
|
|
|
* @throws \UnexpectedValueException |
434
|
|
|
*/ |
435
|
|
View Code Duplication |
public static function createSlug($model, string $attribute, string $fromString, array $config = null): string |
|
|
|
|
436
|
|
|
{ |
437
|
|
|
if (is_string($model)) { |
438
|
|
|
$model = new $model; |
439
|
|
|
} |
440
|
|
|
/** @var static $instance */ |
441
|
|
|
$instance = (new static())->setModel($model); |
442
|
|
|
|
443
|
|
|
if ($config === null) { |
444
|
|
|
$config = Arr::get($model->sluggable(), $attribute); |
445
|
|
|
if ($config === null) { |
446
|
|
|
$modelClass = get_class($model); |
447
|
|
|
throw new \InvalidArgumentException("Argument 2 passed to SlugService::createSlug ['{$attribute}'] is not a valid slug attribute for model {$modelClass}."); |
448
|
|
|
} |
449
|
|
|
} elseif (!is_array($config)) { |
450
|
|
|
throw new \UnexpectedValueException('SlugService::createSlug expects an array or null as the fourth argument; ' . gettype($config) . ' given.'); |
451
|
|
|
} |
452
|
|
|
|
453
|
|
|
$config = $instance->getConfiguration($config); |
454
|
|
|
|
455
|
|
|
$slug = $instance->generateSlug($fromString, $config, $attribute); |
456
|
|
|
$slug = $instance->validateSlug($slug, $config, $attribute); |
457
|
|
|
$slug = $instance->makeSlugUnique($slug, $config, $attribute); |
458
|
|
|
|
459
|
|
|
return $slug; |
460
|
|
|
} |
461
|
|
|
|
462
|
|
|
/** |
463
|
|
|
* @param \Illuminate\Database\Eloquent\Model $model |
464
|
|
|
* |
465
|
|
|
* @return $this |
466
|
|
|
*/ |
467
|
|
|
public function setModel(Model $model) |
468
|
|
|
{ |
469
|
|
|
$this->model = $model; |
470
|
|
|
|
471
|
|
|
return $this; |
472
|
|
|
} |
473
|
|
|
} |
474
|
|
|
|
Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.
You can also find more detailed suggestions in the “Code” section of your repository.