1: <?php
2:
3: namespace Charcoal\Object;
4:
5: use \InvalidArgumentException;
6: use \UnexpectedValueException;
7:
8:
9: use \Charcoal\Loader\CollectionLoader;
10:
11:
12: use \Charcoal\Translation\TranslationString;
13: use \Charcoal\Translation\TranslationConfig;
14:
15:
16: use \Charcoal\View\ViewableInterface;
17:
18:
19: use \Charcoal\Object\ObjectRoute;
20: use \Charcoal\Object\ObjectRouteInterface;
21:
22: 23: 24: 25: 26: 27:
28: trait RoutableTrait
29: {
30: 31: 32: 33: 34:
35: protected $slug;
36:
37: 38: 39: 40: 41: 42: 43: 44:
45: private $isSlugEditable;
46:
47: 48: 49: 50: 51:
52: private $slugPattern = '';
53:
54: 55: 56: 57: 58:
59: private $slugPrefix = '';
60:
61: 62: 63: 64: 65:
66: private $slugSuffix = '';
67:
68: 69: 70: 71: 72:
73: private $latestObjectRoute;
74:
75: 76: 77: 78: 79: 80: 81: 82:
83: private $objectRouteClass = ObjectRoute::class;
84:
85: 86: 87: 88: 89: 90:
91: public function setSlugPattern($pattern)
92: {
93: if (TranslationString::isTranslatable($pattern)) {
94: $this->slugPattern = new TranslationString($pattern);
95: } else {
96: $this->slugPattern = null;
97: }
98:
99: return $this;
100: }
101:
102: 103: 104: 105: 106: 107:
108: public function slugPattern()
109: {
110: if (!$this->slugPattern) {
111: $metadata = $this->metadata();
112:
113: if (isset($metadata['routable']['pattern'])) {
114: $this->setSlugPattern($metadata['routable']['pattern']);
115: } elseif (isset($metadata['slug_pattern'])) {
116: $this->setSlugPattern($metadata['slug_pattern']);
117: } else {
118: throw new InvalidArgumentException(
119: sprintf('Undefined route pattern (slug) for %s', get_called_class())
120: );
121: }
122: }
123:
124: return $this->slugPattern;
125: }
126:
127: 128: 129: 130: 131:
132: public function slugPrefix()
133: {
134: if (!$this->slugPrefix) {
135: $metadata = $this->metadata();
136:
137: if (isset($metadata['routable']['prefix'])) {
138: $affix = $metadata['routable']['prefix'];
139:
140: if (TranslationString::isTranslatable($affix)) {
141: $this->slugPrefix = new TranslationString($affix);
142: }
143: }
144: }
145:
146: return $this->slugPrefix;
147: }
148:
149: 150: 151: 152: 153:
154: public function slugSuffix()
155: {
156: if (!$this->slugSuffix) {
157: $metadata = $this->metadata();
158:
159: if (isset($metadata['routable']['suffix'])) {
160: $affix = $metadata['routable']['suffix'];
161:
162: if (TranslationString::isTranslatable($affix)) {
163: $this->slugSuffix = new TranslationString($affix);
164: }
165: }
166: }
167:
168: return $this->slugSuffix;
169: }
170:
171: 172: 173: 174: 175:
176: public function isSlugEditable()
177: {
178: if ($this->isSlugEditable === null) {
179: $metadata = $this->metadata();
180:
181: if (isset($metadata['routable']['editable'])) {
182: $this->isSlugEditable = !!$metadata['routable']['editable'];
183: } else {
184: $this->isSlugEditable = false;
185: }
186: }
187:
188: return $this->isSlugEditable;
189: }
190:
191: 192: 193: 194: 195: 196:
197: public function setSlug($slug)
198: {
199: if (TranslationString::isTranslatable($slug)) {
200: $this->slug = new TranslationString($slug);
201:
202: $values = $this->slug->all();
203: foreach ($values as $lang => $val) {
204: $this->slug[$lang] = $this->slugify($val);
205: }
206: } else {
207:
208: if (isset($_POST['slug'])) {
209: $this->slug = [];
210: } else {
211: $this->slug = null;
212: }
213: }
214:
215: return $this;
216: }
217:
218: 219: 220: 221: 222:
223: public function slug()
224: {
225: return $this->slug;
226: }
227:
228: 229: 230: 231: 232: 233:
234: public function generateSlug()
235: {
236: $translator = TranslationConfig::instance();
237: $languages = $translator->availableLanguages();
238: $patterns = $this->slugPattern();
239: $curSlug = $this->slug();
240: $newSlug = new TranslationString();
241:
242: $origLang = $translator->currentLanguage();
243: foreach ($languages as $lang) {
244: $pattern = $patterns[$lang];
245:
246: $translator->setCurrentLanguage($lang);
247: if ($this->isSlugEditable() && isset($curSlug[$lang]) && strlen($curSlug[$lang])) {
248: $newSlug[$lang] = $curSlug[$lang];
249: } else {
250: $newSlug[$lang] = $this->generateRoutePattern($pattern);
251: if (!strlen($newSlug[$lang])) {
252: throw new UnexpectedValueException(
253: sprintf('The slug is empty. The pattern is "%s"', $pattern)
254: );
255: }
256: }
257: $newSlug[$lang] = $this->finalizeSlug($newSlug[$lang]);
258:
259: $objectRoute = $this->createRouteObject();
260: if ($objectRoute->source()->tableExists()) {
261: $objectRoute->setData([
262: 'lang' => $lang,
263: 'slug' => $newSlug[$lang],
264: 'route_obj_type' => $this->objType(),
265: 'route_obj_id' => $this->id()
266: ]);
267:
268: if (!$objectRoute->isSlugUnique()) {
269: $objectRoute->generateUniqueSlug();
270: $newSlug[$lang] = $objectRoute->slug();
271: }
272: }
273: }
274: $translator->setCurrentLanguage($origLang);
275:
276: return $newSlug;
277: }
278:
279: 280: 281: 282: 283: 284: 285:
286: protected function generateRoutePattern($pattern)
287: {
288: if ($this instanceof ViewableInterface && $this->view() !== null) {
289: $route = $this->view()->render($pattern, $this->viewController());
290: } else {
291: $route = preg_replace_callback('~\{\{\s*(.*?)\s*\}\}~i', [$this, 'parseRouteToken'], $pattern);
292: }
293:
294: return $this->slugify($route);
295: }
296:
297: 298: 299: 300: 301: 302: 303: 304: 305:
306: protected function parseRouteToken($token)
307: {
308:
309: if (is_array($token) && isset($token[1])) {
310: $token = $token[1];
311: }
312:
313: $token = trim($token);
314: $method = [$this, $token];
315:
316: if (is_callable($method)) {
317: $value = call_user_func($method);
318:
319: } elseif (isset($this[$token])) {
320: $value = $this[$token];
321: } else {
322: return '';
323: }
324:
325: $value = $this->filterRouteToken($value, $token);
326: if (!is_string($value) && !is_numeric($value)) {
327: throw new InvalidArgumentException(
328: sprintf(
329: 'Route token "%1$s" must be a string with %2$s; received %3$s',
330: $token,
331: get_called_class(),
332: (is_object($value) ? get_class($value) : gettype($value))
333: )
334: );
335: }
336:
337: return $value;
338: }
339:
340: 341: 342: 343: 344: 345: 346: 347:
348: protected function filterRouteToken($value, $token = null)
349: {
350: unset($token);
351:
352: if ($value instanceof \Closure) {
353: $value = $value();
354: }
355:
356: if ($value instanceof \DateTime) {
357: $value = $value->format('Y-m-d-H:i');
358: }
359:
360: if (method_exists($value, '__toString')) {
361: $value = strval($value);
362: }
363:
364: return $value;
365: }
366:
367: 368: 369: 370: 371: 372: 373: 374:
375: protected function generateObjectRoute($slug = null)
376: {
377: $translator = TranslationConfig::instance();
378:
379: if (!$slug) {
380: $slug = $this->generateSlug();
381: }
382:
383: if ($slug instanceof TranslationString) {
384: $slugs = $slug->all();
385: }
386:
387: $origLang = $translator->currentLanguage();
388: foreach ($slugs as $lang => $slug) {
389: if (!$translator->hasLanguage($lang)) {
390: continue;
391: }
392:
393: $translator->setCurrentLanguage($lang);
394:
395: $objectRoute = $this->createRouteObject();
396:
397: $source = $objectRoute->source();
398: if (!$source->tableExists()) {
399: $source->createTable();
400: } else {
401: $oldRoute = $this->getLatestObjectRoute();
402:
403:
404: if ($slug === $oldRoute->slug()) {
405: $oldRoute->setData([
406: 'route_template' => $this->templateIdent()
407: ]);
408: $oldRoute->update(['route_template']);
409: continue;
410: }
411: }
412:
413: $objectRoute->setData([
414: 'lang' => $lang,
415: 'slug' => $slug,
416: 'route_obj_type' => $this->objType(),
417: 'route_obj_id' => $this->id(),
418:
419: 'route_template' => $this->templateIdent(),
420: 'active' => true
421: ]);
422:
423: if (!$objectRoute->isSlugUnique()) {
424: $objectRoute->generateUniqueSlug();
425: }
426:
427: if ($objectRoute->id()) {
428: $objectRoute->update();
429: } else {
430: $objectRoute->save();
431: }
432: }
433:
434: $translator->setCurrentLanguage($origLang);
435: }
436:
437: 438: 439: 440: 441: 442: 443:
444: protected function getLatestObjectRoute($lang = null)
445: {
446: $translator = TranslationConfig::instance();
447:
448: if ($lang === null) {
449: $lang = $translator->currentLanguage();
450: } elseif (!$translator->hasLanguage($lang)) {
451: throw new InvalidArgumentException(
452: sprintf(
453: 'Invalid language, received %s',
454: (is_object($lang) ? get_class($lang) : gettype($lang))
455: )
456: );
457: }
458:
459: if (isset($this->latestObjectRoute[$lang])) {
460: return $this->latestObjectRoute[$lang];
461: }
462:
463: $model = $this->createRouteObject();
464:
465: if (!$this->objType() || !$this->id()) {
466: $this->latestObjectRoute[$lang] = $model;
467:
468: return $this->latestObjectRoute[$lang];
469: }
470:
471:
472: $source = $model->source();
473: $loader = new CollectionLoader([
474: 'logger' => $this->logger,
475: 'factory' => $this->modelFactory()
476: ]);
477:
478: if (!$source->tableExists()) {
479: $source->createTable();
480: }
481:
482: $loader
483: ->setModel($model)
484: ->addFilter('route_obj_type', $this->objType())
485: ->addFilter('route_obj_id', $this->id())
486: ->addFilter('lang', $lang)
487: ->addFilter('active', true)
488: ->addOrder('creation_date', 'desc')
489: ->setPage(1)
490: ->setNumPerPage(1);
491:
492: $collection = $loader->load()->objects();
493:
494: if (!count($collection)) {
495: $this->latestObjectRoute[$lang] = $model;
496:
497: return $this->latestObjectRoute[$lang];
498: }
499:
500: $this->latestObjectRoute[$lang] = $collection[0];
501:
502: return $this->latestObjectRoute[$lang];
503: }
504:
505: 506: 507: 508: 509: 510:
511: public function url($lang = null)
512: {
513: $url = (string)$this->getLatestObjectRoute($lang)->slug();
514: if ($url) {
515: return $url;
516: }
517:
518: $slug = $this->slug();
519:
520: if ($slug instanceof TranslationString && $lang) {
521: return $slug->val($lang);
522: }
523:
524: return (string)$slug;
525: }
526:
527: 528: 529: 530: 531: 532:
533: public function slugify($str)
534: {
535: static $sluggedArray;
536:
537: if (isset($sluggedArray[$str])) {
538: return $sluggedArray[$str];
539: }
540:
541: $metadata = $this->metadata();
542: $separator = isset($metadata['routable']['separator']) ? $metadata['routable']['separator'] : '-';
543: $delimiters = '-_|';
544: $pregDelim = preg_quote($delimiters);
545: $directories = '\\/';
546: $pregDir = preg_quote($directories);
547:
548:
549: $slug = preg_replace('![^(\p{L}|\p{N})(\s|\/)]!u', $separator, $str);
550:
551: if (!isset($metadata['routable']['lowercase']) || $metadata['routable']['lowercase'] === false) {
552: $slug = mb_strtolower($slug, 'UTF-8');
553: }
554:
555:
556: $slug = strip_tags($slug);
557:
558:
559: $slug = preg_replace(
560: '!&([a-zA-Z])(uml|acute|grave|circ|tilde|cedil|ring);!',
561: '$1',
562: htmlentities($slug, ENT_COMPAT, 'UTF-8')
563: );
564:
565:
566: $unescaped = '!&(raquo|laquo|rsaquo|lsaquo|rdquo|ldquo|rsquo|lsquo|hellip|amp|nbsp|quot|ordf|ordm);!';
567: $slug = preg_replace($unescaped, '', $slug);
568:
569:
570: $flip = ($separator === '-') ? '_' : '-';
571: $slug = preg_replace('!['.preg_quote($flip).']+!u', $separator, $slug);
572:
573:
574: $slug = preg_replace('![_\|\s]+!', $separator, $slug);
575:
576:
577: $slug = preg_replace('!['.$pregDelim.'\s]{2,}!', $separator, $slug);
578:
579:
580: $slug = preg_replace('!['.$pregDir.']{2,}!', $separator, $slug);
581:
582:
583: $slug = preg_replace('!(?<=['.$pregDir.'])['.$pregDelim.']|['.$pregDelim.'](?=['.$pregDir.'])!', '', $slug);
584:
585:
586: $slug = trim($slug, $delimiters);
587:
588:
589: $sluggedArray[$str] = $slug;
590:
591: return $slug;
592: }
593:
594: 595: 596: 597: 598: 599: 600: 601: 602:
603: protected function finalizeSlug($slug)
604: {
605: $prefix = $this->slugPrefix();
606: if ($prefix) {
607: $prefix = $this->generateRoutePattern((string)$prefix);
608: if ($slug === $prefix) {
609: throw new UnexpectedValueException('The slug is the same as the prefix.');
610: }
611: $slug = $prefix.preg_replace('!^'.preg_quote($prefix).'\b!', '', $slug);
612: }
613:
614: $suffix = $this->slugSuffix();
615: if ($suffix) {
616: $suffix = $this->generateRoutePattern((string)$suffix);
617: if ($slug === $suffix) {
618: throw new UnexpectedValueException('The slug is the same as the suffix.');
619: }
620: $slug = preg_replace('!\b'.preg_quote($suffix).'$!', '', $slug).$suffix;
621: }
622:
623: return $slug;
624: }
625:
626: 627: 628: 629: 630: 631: 632:
633: protected function deleteObjectRoutes()
634: {
635: if (!$this->objType()) {
636: return false;
637: }
638:
639: if (!$this->id()) {
640: return false;
641: }
642:
643: $model = $this->modelFactory()->get($this->objectRouteClass());
644: $loader = new CollectionLoader([
645: 'logger' => $this->logger,
646: 'factory' => $this->modelFactory()
647: ]);
648:
649: $loader
650: ->setModel($model)
651: ->addFilter('route_obj_type', $this->objType())
652: ->addFilter('route_obj_id', $this->id());
653:
654: $collection = $loader->load();
655: foreach ($collection as $route) {
656: $route->delete();
657: }
658:
659: return true;
660: }
661:
662: 663: 664: 665: 666:
667: public function createRouteObject()
668: {
669: $route = $this->modelFactory()->create($this->objectRouteClass());
670:
671: return $route;
672: }
673:
674: 675: 676: 677: 678: 679: 680:
681: protected function setObjectRouteClass($className)
682: {
683: if (!is_string($className)) {
684: throw new InvalidArgumentException(
685: 'Route class name must be a string.'
686: );
687: }
688:
689: $this->objectRouteClass = $className;
690:
691: return $this;
692: }
693:
694: 695: 696: 697: 698:
699: public function objectRouteClass()
700: {
701: return $this->objectRouteClass;
702: }
703:
704: 705: 706: 707: 708:
709: abstract public function modelFactory();
710:
711: 712: 713: 714: 715:
716: abstract public function templateIdent();
717: }
718: