Overview

Namespaces

  • Charcoal
    • Object
    • User
      • Acl

Classes

  • Charcoal\Object\Content
  • Charcoal\Object\ObjectRevision
  • Charcoal\Object\ObjectRoute
  • Charcoal\Object\ObjectSchedule
  • Charcoal\Object\UserData
  • Charcoal\User\AbstractUser
  • Charcoal\User\Acl\Manager
  • Charcoal\User\Acl\Permission
  • Charcoal\User\Acl\PermissionCategory
  • Charcoal\User\Acl\Role
  • Charcoal\User\Authenticator
  • Charcoal\User\Authorizer
  • Charcoal\User\AuthToken
  • Charcoal\User\AuthTokenMetadata
  • Charcoal\User\GenericUser

Interfaces

  • Charcoal\Object\ArchivableInterface
  • Charcoal\Object\CategorizableInterface
  • Charcoal\Object\CategorizableMultipleInterface
  • Charcoal\Object\CategoryInterface
  • Charcoal\Object\ContentInterface
  • Charcoal\Object\HierarchicalInterface
  • Charcoal\Object\ObjectRevisionInterface
  • Charcoal\Object\ObjectRouteInterface
  • Charcoal\Object\ObjectScheduleInterface
  • Charcoal\Object\PublishableInterface
  • Charcoal\Object\RevisionableInterface
  • Charcoal\Object\RoutableInterface
  • Charcoal\Object\UserDataInterface
  • Charcoal\User\UserInterface

Traits

  • Charcoal\Object\ArchivableTrait
  • Charcoal\Object\CategorizableMultipleTrait
  • Charcoal\Object\CategorizableTrait
  • Charcoal\Object\CategoryTrait
  • Charcoal\Object\HierarchicalTrait
  • Charcoal\Object\PublishableTrait
  • Charcoal\Object\RevisionableTrait
  • Charcoal\Object\RoutableTrait
  • Overview
  • Namespace
  • Class
  1: <?php
  2: 
  3: namespace Charcoal\Object;
  4: 
  5: use \InvalidArgumentException;
  6: use \UnexpectedValueException;
  7: 
  8: // From 'charcoal-core'
  9: use \Charcoal\Loader\CollectionLoader;
 10: 
 11: // From 'charcoal-translation'
 12: use \Charcoal\Translation\TranslationString;
 13: use \Charcoal\Translation\TranslationConfig;
 14: 
 15: // From 'charcoal-view'
 16: use \Charcoal\View\ViewableInterface;
 17: 
 18: // Local Dependencies
 19: use \Charcoal\Object\ObjectRoute;
 20: use \Charcoal\Object\ObjectRouteInterface;
 21: 
 22: /**
 23:  * Full implementation, as Trait, of the {@see \Charcoal\Object\RoutableInterface}.
 24:  *
 25:  * This implementation uses a secondary model, {@see \Charcoal\Object\ObjectRoute},
 26:  * to collect all routes of routable models under a single source.
 27:  */
 28: trait RoutableTrait
 29: {
 30:     /**
 31:      * The object's route.
 32:      *
 33:      * @var TranslationString|string|null
 34:      */
 35:     protected $slug;
 36: 
 37:     /**
 38:      * Whether the slug is editable.
 39:      *
 40:      * If FALSE, the slug is always auto-generated from its pattern.
 41:      * If TRUE, the slug is auto-generated only if the slug is empty.
 42:      *
 43:      * @var boolean|null
 44:      */
 45:     private $isSlugEditable;
 46: 
 47:     /**
 48:      * The object's route pattern.
 49:      *
 50:      * @var TranslationString|string|null
 51:      */
 52:     private $slugPattern = '';
 53: 
 54:     /**
 55:      * A prefix for the object's route.
 56:      *
 57:      * @var TranslationString|string|null
 58:      */
 59:     private $slugPrefix = '';
 60: 
 61:     /**
 62:      * A suffix for the object's route.
 63:      *
 64:      * @var TranslationString|string|null
 65:      */
 66:     private $slugSuffix = '';
 67: 
 68:     /**
 69:      * Latest ObjectRoute object concerning the current object.
 70:      *
 71:      * @var ObjectRouteInterface
 72:      */
 73:     private $latestObjectRoute;
 74: 
 75:     /**
 76:      * The class name of the object route model.
 77:      *
 78:      * Must be a fully-qualified PHP namespace and an implementation of
 79:      * {@see \Charcoal\Object\ObjectRouteInterface}. Used by the model factory.
 80:      *
 81:      * @var string
 82:      */
 83:     private $objectRouteClass = ObjectRoute::class;
 84: 
 85:     /**
 86:      * Set the object's URL slug pattern.
 87:      *
 88:      * @param  mixed $pattern The slug pattern.
 89:      * @return RoutableInterface Chainable
 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:      * Retrieve the object's URL slug pattern.
104:      *
105:      * @throws InvalidArgumentException If a slug pattern is not defined.
106:      * @return TranslationString|null
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:      * Retrieve route prefix for the object's URL slug pattern.
129:      *
130:      * @return TranslationString|null
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:      * Retrieve route suffix for the object's URL slug pattern.
151:      *
152:      * @return TranslationString|null
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:      * Determine if the slug is editable.
173:      *
174:      * @return boolean
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:      * Set the object's URL slug.
193:      *
194:      * @param  mixed $slug The slug.
195:      * @return RoutableInterface Chainable
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:             /** @todo Hack used for regenerating route */
208:             if (isset($_POST['slug'])) {
209:                 $this->slug = [];
210:             } else {
211:                 $this->slug = null;
212:             }
213:         }
214: 
215:         return $this;
216:     }
217: 
218:     /**
219:      * Retrieve the object's URL slug.
220:      *
221:      * @return TranslationString|null
222:      */
223:     public function slug()
224:     {
225:         return $this->slug;
226:     }
227: 
228:     /**
229:      * Generate a URL slug from the object's URL slug pattern.
230:      *
231:      * @throws UnexpectedValueException If the slug is empty.
232:      * @return TranslationString
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:      * Generate a route from the given pattern.
281:      *
282:      * @uses   self::parseRouteToken() If a view renderer is unavailable.
283:      * @param  string $pattern The slug pattern.
284:      * @return string Returns the generated route.
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:      * Parse the given slug (URI token) for the current object.
299:      *
300:      * @used-by self::generateRoutePattern() If a view renderer is unavailable.
301:      * @uses    self::filterRouteToken() For customize the route value filtering,
302:      * @param   string|array $token The token to parse relative to the model entry.
303:      * @throws  InvalidArgumentException If a route token is not a string.
304:      * @return  string
305:      */
306:     protected function parseRouteToken($token)
307:     {
308:         // Processes matches from a regular expression operation
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:             /** @see \Charcoal\Config\AbstractEntity::offsetGet() */
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:      * Filter the given value for a URI.
342:      *
343:      * @used-by self::parseRouteToken() To resolve the token's value.
344:      * @param   mixed  $value A value to filter.
345:      * @param   string $token The parsed token.
346:      * @return  string The filtered $value.
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:      * Route generation.
369:      *
370:      * Saves all routes to {@see \Charcoal\Object\ObjectRoute}.
371:      *
372:      * @param  mixed $slug Slug by langs.
373:      * @return void
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:                 // Unchanged but sync extra properties
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:                 // Not used, might be too much.
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:      * Retrieve the latest object route.
439:      *
440:      * @param  string|null $lang If object is multilingual, return the object route for the specified locale.
441:      * @throws InvalidArgumentException If the given language is invalid.
442:      * @return ObjectRouteInterface Latest object route.
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:         // For URL.
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:      * Retrieve the object's URI.
507:      *
508:      * @param  string|null $lang If object is multilingual, return the object route for the specified locale.
509:      * @return TranslationString|string
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:      * Convert a string into a slug.
529:      *
530:      * @param  string $str The string to slugify.
531:      * @return string The slugified string.
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:         // Do NOT remove forward slashes.
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:         // Strip HTML
556:         $slug = strip_tags($slug);
557: 
558:         // Remove diacritics
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:         // Remove unescaped HTML characters
566:         $unescaped = '!&(raquo|laquo|rsaquo|lsaquo|rdquo|ldquo|rsquo|lsquo|hellip|amp|nbsp|quot|ordf|ordm);!';
567:         $slug      = preg_replace($unescaped, '', $slug);
568: 
569:         // Unify all dashes/underscores as one separator character
570:         $flip = ($separator === '-') ? '_' : '-';
571:         $slug = preg_replace('!['.preg_quote($flip).']+!u', $separator, $slug);
572: 
573:         // Remove all whitespace and normalize delimiters
574:         $slug = preg_replace('![_\|\s]+!', $separator, $slug);
575: 
576:         // Squeeze multiple delimiters and whitespace with a single separator
577:         $slug = preg_replace('!['.$pregDelim.'\s]{2,}!', $separator, $slug);
578: 
579:         // Squeeze multiple URI path delimiters
580:         $slug = preg_replace('!['.$pregDir.']{2,}!', $separator, $slug);
581: 
582:         // Remove delimiters surrouding URI path delimiters
583:         $slug = preg_replace('!(?<=['.$pregDir.'])['.$pregDelim.']|['.$pregDelim.'](?=['.$pregDir.'])!', '', $slug);
584: 
585:         // Strip leading and trailing dashes or underscores
586:         $slug = trim($slug, $delimiters);
587: 
588:         // Cache the slugified string
589:         $sluggedArray[$str] = $slug;
590: 
591:         return $slug;
592:     }
593: 
594:     /**
595:      * Finalize slug.
596:      *
597:      * Adds any prefix and suffix defined in the routable configuration set.
598:      *
599:      * @param  string $slug A slug.
600:      * @throws UnexpectedValueException If the slug affixes are invalid.
601:      * @return string
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:      * Delete all object routes.
628:      *
629:      * Should be called on object deletion {@see \Charcoal\Model\AbstractModel::preDelete()}.
630:      *
631:      * @return boolean Success or failure.
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:      * Create a route object.
664:      *
665:      * @return ObjectRouteInterface
666:      */
667:     public function createRouteObject()
668:     {
669:         $route = $this->modelFactory()->create($this->objectRouteClass());
670: 
671:         return $route;
672:     }
673: 
674:     /**
675:      * Set the class name of the object route model.
676:      *
677:      * @param  string $className The class name of the object route model.
678:      * @throws InvalidArgumentException If the class name is not a string.
679:      * @return AbstractPropertyDisplay Chainable
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:      * Retrieve the class name of the object route model.
696:      *
697:      * @return string
698:      */
699:     public function objectRouteClass()
700:     {
701:         return $this->objectRouteClass;
702:     }
703: 
704:     /**
705:      * Retrieve the object model factory.
706:      *
707:      * @return \Charcoal\Factory\FactoryInterface
708:      */
709:     abstract public function modelFactory();
710: 
711:     /**
712:      * Retrieve the routable object's template identifier.
713:      *
714:      * @return mixed
715:      */
716:     abstract public function templateIdent();
717: }
718: 
API documentation generated by ApiGen