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