]> BookStack Code Mirror - bookstack/blob - app/Entities/Models/Entity.php
Slugs: Rolled out history lookup to other types
[bookstack] / app / Entities / Models / Entity.php
1 <?php
2
3 namespace BookStack\Entities\Models;
4
5 use BookStack\Activity\Models\Activity;
6 use BookStack\Activity\Models\Comment;
7 use BookStack\Activity\Models\Favouritable;
8 use BookStack\Activity\Models\Favourite;
9 use BookStack\Activity\Models\Loggable;
10 use BookStack\Activity\Models\Tag;
11 use BookStack\Activity\Models\View;
12 use BookStack\Activity\Models\Viewable;
13 use BookStack\Activity\Models\Watch;
14 use BookStack\App\Model;
15 use BookStack\App\SluggableInterface;
16 use BookStack\Permissions\JointPermissionBuilder;
17 use BookStack\Permissions\Models\EntityPermission;
18 use BookStack\Permissions\Models\JointPermission;
19 use BookStack\Permissions\PermissionApplicator;
20 use BookStack\References\Reference;
21 use BookStack\Search\SearchIndex;
22 use BookStack\Search\SearchTerm;
23 use BookStack\Users\Models\HasCreatorAndUpdater;
24 use BookStack\Users\Models\OwnableInterface;
25 use BookStack\Users\Models\User;
26 use Carbon\Carbon;
27 use Illuminate\Database\Eloquent\Builder;
28 use Illuminate\Database\Eloquent\Collection;
29 use Illuminate\Database\Eloquent\Relations\BelongsTo;
30 use Illuminate\Database\Eloquent\Relations\HasOne;
31 use Illuminate\Database\Eloquent\Relations\MorphMany;
32 use Illuminate\Database\Eloquent\SoftDeletes;
33
34 /**
35  * Class Entity
36  * The base class for book-like items such as pages, chapters and books.
37  * This is not a database model in itself but extended.
38  *
39  * @property int        $id
40  * @property string     $type
41  * @property string     $name
42  * @property string     $slug
43  * @property Carbon     $created_at
44  * @property Carbon     $updated_at
45  * @property Carbon     $deleted_at
46  * @property int|null   $created_by
47  * @property int|null   $updated_by
48  * @property int|null   $owned_by
49  * @property Collection $tags
50  *
51  * @method static Entity|Builder visible()
52  * @method static Builder withLastView()
53  * @method static Builder withViewCount()
54  */
55 abstract class Entity extends Model implements
56     SluggableInterface,
57     Favouritable,
58     Viewable,
59     DeletableInterface,
60     OwnableInterface,
61     Loggable
62 {
63     use SoftDeletes;
64     use HasCreatorAndUpdater;
65
66     /**
67      * @var string - Name of property where the main text content is found
68      */
69     public string $textField = 'description';
70
71     /**
72      * @var string - Name of the property where the main HTML content is found
73      */
74     public string $htmlField = 'description_html';
75
76     /**
77      * @var float - Multiplier for search indexing.
78      */
79     public float $searchFactor = 1.0;
80
81     /**
82      * Set the table to be that used by all entities.
83      */
84     protected $table = 'entities';
85
86     /**
87      * Set a custom query builder for entities.
88      */
89     protected static string $builder = EntityQueryBuilder::class;
90
91     public static array $commonFields = [
92         'id',
93         'type',
94         'name',
95         'slug',
96         'book_id',
97         'chapter_id',
98         'priority',
99         'created_at',
100         'updated_at',
101         'deleted_at',
102         'created_by',
103         'updated_by',
104         'owned_by',
105     ];
106
107     /**
108      * Override the save method to also save the contents for convenience.
109      */
110     public function save(array $options = []): bool
111     {
112         /** @var EntityPageData|EntityContainerData $contents */
113         $contents = $this->relatedData()->firstOrNew();
114         $contentFields = $this->getContentsAttributes();
115
116         foreach ($contentFields as $key => $value) {
117             $contents->setAttribute($key, $value);
118             unset($this->attributes[$key]);
119         }
120
121         $this->setAttribute('type', $this->getMorphClass());
122         $result = parent::save($options);
123         $contentsResult = true;
124
125         if ($result && $contents->isDirty()) {
126             $contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()];
127             $contents->forceFill($contentsFillData);
128             $contentsResult = $contents->save();
129             $this->touch();
130         }
131
132         $this->forceFill($contentFields);
133
134         return $result && $contentsResult;
135     }
136
137     /**
138      * Check if this item is a container item.
139      */
140     public function isContainer(): bool
141     {
142         return $this instanceof Bookshelf ||
143             $this instanceof Book ||
144             $this instanceof Chapter;
145     }
146
147     /**
148      * Get the entities that are visible to the current user.
149      */
150     public function scopeVisible(Builder $query): Builder
151     {
152         return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
153     }
154
155     /**
156      * Query scope to get the last view from the current user.
157      */
158     public function scopeWithLastView(Builder $query)
159     {
160         $viewedAtQuery = View::query()->select('updated_at')
161             ->whereColumn('viewable_id', '=', 'entities.id')
162             ->whereColumn('viewable_type', '=', 'entities.type')
163             ->where('user_id', '=', user()->id)
164             ->take(1);
165
166         return $query->addSelect(['last_viewed_at' => $viewedAtQuery]);
167     }
168
169     /**
170      * Query scope to get the total view count of the entities.
171      */
172     public function scopeWithViewCount(Builder $query): void
173     {
174         $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
175             ->whereColumn('viewable_id', '=', 'entities.id')
176             ->whereColumn('viewable_type', '=', 'entities.type')
177             ->take(1);
178
179         $query->addSelect(['view_count' => $viewCountQuery]);
180     }
181
182     /**
183      * Compares this entity to another given entity.
184      * Matches by comparing class and id.
185      */
186     public function matches(self $entity): bool
187     {
188         return [get_class($this), $this->id] === [get_class($entity), $entity->id];
189     }
190
191     /**
192      * Checks if the current entity matches or contains the given.
193      */
194     public function matchesOrContains(self $entity): bool
195     {
196         if ($this->matches($entity)) {
197             return true;
198         }
199
200         if (($entity instanceof BookChild) && $this instanceof Book) {
201             return $entity->book_id === $this->id;
202         }
203
204         if ($entity instanceof Page && $this instanceof Chapter) {
205             return $entity->chapter_id === $this->id;
206         }
207
208         return false;
209     }
210
211     /**
212      * Gets the activity objects for this entity.
213      */
214     public function activity(): MorphMany
215     {
216         return $this->morphMany(Activity::class, 'loggable')
217             ->orderBy('created_at', 'desc');
218     }
219
220     /**
221      * Get View objects for this entity.
222      */
223     public function views(): MorphMany
224     {
225         return $this->morphMany(View::class, 'viewable');
226     }
227
228     /**
229      * Get the Tag models that have been user assigned to this entity.
230      */
231     public function tags(): MorphMany
232     {
233         return $this->morphMany(Tag::class, 'entity')
234             ->orderBy('order', 'asc');
235     }
236
237     /**
238      * Get the comments for an entity.
239      * @return MorphMany<Comment, $this>
240      */
241     public function comments(bool $orderByCreated = true): MorphMany
242     {
243         $query = $this->morphMany(Comment::class, 'commentable');
244
245         return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
246     }
247
248     /**
249      * Get the related search terms.
250      */
251     public function searchTerms(): MorphMany
252     {
253         return $this->morphMany(SearchTerm::class, 'entity');
254     }
255
256     /**
257      * Get this entities assigned permissions.
258      */
259     public function permissions(): MorphMany
260     {
261         return $this->morphMany(EntityPermission::class, 'entity');
262     }
263
264     /**
265      * Check if this entity has a specific restriction set against it.
266      */
267     public function hasPermissions(): bool
268     {
269         return $this->permissions()->count() > 0;
270     }
271
272     /**
273      * Get the entity jointPermissions this is connected to.
274      */
275     public function jointPermissions(): MorphMany
276     {
277         return $this->morphMany(JointPermission::class, 'entity');
278     }
279
280     /**
281      * Get the user who owns this entity.
282      * @return BelongsTo<User, $this>
283      */
284     public function ownedBy(): BelongsTo
285     {
286         return $this->belongsTo(User::class, 'owned_by');
287     }
288
289     public function getOwnerFieldName(): string
290     {
291         return 'owned_by';
292     }
293
294     /**
295      * Get the related delete records for this entity.
296      */
297     public function deletions(): MorphMany
298     {
299         return $this->morphMany(Deletion::class, 'deletable');
300     }
301
302     /**
303      * Get the references pointing from this entity to other items.
304      */
305     public function referencesFrom(): MorphMany
306     {
307         return $this->morphMany(Reference::class, 'from');
308     }
309
310     /**
311      * Get the references pointing to this entity from other items.
312      */
313     public function referencesTo(): MorphMany
314     {
315         return $this->morphMany(Reference::class, 'to');
316     }
317
318     /**
319      * Check if this instance or class is a certain type of entity.
320      * Examples of $type are 'page', 'book', 'chapter'.
321      *
322      * @deprecated Use instanceof instead.
323      */
324     public static function isA(string $type): bool
325     {
326         return static::getType() === strtolower($type);
327     }
328
329     /**
330      * Get the entity type as a simple lowercase word.
331      */
332     public static function getType(): string
333     {
334         $className = array_slice(explode('\\', static::class), -1, 1)[0];
335
336         return strtolower($className);
337     }
338
339     /**
340      * Gets a limited-length version of the entity name.
341      */
342     public function getShortName(int $length = 25): string
343     {
344         if (mb_strlen($this->name) <= $length) {
345             return $this->name;
346         }
347
348         return mb_substr($this->name, 0, $length - 3) . '...';
349     }
350
351     /**
352      * Get an excerpt of this entity's descriptive content to the specified length.
353      */
354     public function getExcerpt(int $length = 100): string
355     {
356         $text = $this->{$this->textField} ?? '';
357
358         if (mb_strlen($text) > $length) {
359             $text = mb_substr($text, 0, $length - 3) . '...';
360         }
361
362         return trim($text);
363     }
364
365     /**
366      * Get the url of this entity.
367      */
368     abstract public function getUrl(string $path = '/'): string;
369
370     /**
371      * Get the parent entity if existing.
372      * This is the "static" parent and does not include dynamic
373      * relations such as shelves to books.
374      */
375     public function getParent(): ?self
376     {
377         if ($this instanceof Page) {
378             /** @var BelongsTo<Chapter|Book, Page>  $builder */
379             $builder = $this->chapter_id ? $this->chapter() : $this->book();
380             return $builder->withTrashed()->first();
381         }
382         if ($this instanceof Chapter) {
383             /** @var BelongsTo<Book, Page>  $builder */
384             $builder = $this->book();
385             return $builder->withTrashed()->first();
386         }
387
388         return null;
389     }
390
391     /**
392      * Rebuild the permissions for this entity.
393      */
394     public function rebuildPermissions(): void
395     {
396         app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
397     }
398
399     /**
400      * Index the current entity for search.
401      */
402     public function indexForSearch(): void
403     {
404         app()->make(SearchIndex::class)->indexEntity(clone $this);
405     }
406
407     /**
408      * {@inheritdoc}
409      */
410     public function favourites(): MorphMany
411     {
412         return $this->morphMany(Favourite::class, 'favouritable');
413     }
414
415     /**
416      * Check if the entity is a favourite of the current user.
417      */
418     public function isFavourite(): bool
419     {
420         return $this->favourites()
421             ->where('user_id', '=', user()->id)
422             ->exists();
423     }
424
425     /**
426      * Get the related watches for this entity.
427      */
428     public function watches(): MorphMany
429     {
430         return $this->morphMany(Watch::class, 'watchable');
431     }
432
433     /**
434      * Get the related slug history for this entity.
435      */
436     public function slugHistory(): MorphMany
437     {
438         return $this->morphMany(SlugHistory::class, 'sluggable');
439     }
440
441     /**
442      * {@inheritdoc}
443      */
444     public function logDescriptor(): string
445     {
446         return "({$this->id}) {$this->name}";
447     }
448
449     /**
450      * @return HasOne<covariant (EntityContainerData|EntityPageData), $this>
451      */
452     abstract public function relatedData(): HasOne;
453
454     /**
455      * Get the attributes that are intended for the related contents model.
456      * @return array<string, mixed>
457      */
458     protected function getContentsAttributes(): array
459     {
460         $contentFields = [];
461         $contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class;
462
463         foreach ($this->attributes as $key => $value) {
464             if (in_array($key, $contentModel::$fields)) {
465                 $contentFields[$key] = $value;
466             }
467         }
468
469         return $contentFields;
470     }
471
472     /**
473      * Create a new instance for the given entity type.
474      */
475     public static function instanceFromType(string $type): self
476     {
477         return match ($type) {
478             'page' => new Page(),
479             'chapter' => new Chapter(),
480             'book' => new Book(),
481             'bookshelf' => new Bookshelf(),
482         };
483     }
484 }