This commit is contained in:
Richard Dern
2022-01-12 00:35:37 +01:00
commit 400e3d01f1
1363 changed files with 57778 additions and 0 deletions

49
app/Models/Bookmark.php Executable file
View File

@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
/**
* Link between a folder (belonging to a user) and a document.
*/
class Bookmark extends Pivot
{
// -------------------------------------------------------------------------
// ----| Properties |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Name of the table storing bookmarks.
*
* @var string
*/
public $table = 'bookmarks';
/**
* Indicates if the IDs are auto-incrementing.
*
* @var bool
*/
public $incrementing = true;
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Associated document.
*/
public function document()
{
return $this->belongsTo(Document::class);
}
/**
* Associated folder.
*/
public function folder()
{
return $this->belongsTo(Folder::class);
}
}

288
app/Models/Document.php Executable file
View File

@@ -0,0 +1,288 @@
<?php
namespace App\Models;
use App\Models\Traits\Document\AnalysesDocument;
use App\Models\Traits\HasUrl;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class Document extends Model
{
use AnalysesDocument;
use HasUrl;
// -------------------------------------------------------------------------
// ----| Properties |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'url',
];
/**
* Array of folders containing this document. User will be specified in the
* findDupplicatesFor method.
*/
protected $dupplicates = [];
/**
* The accessors to append to the model's array form.
*
* @var array
*/
protected $appends = [
'dupplicates',
'favicon',
'ascii_url',
];
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = [
'checked_at',
];
/**
* Hash of URL.
*
* @var string
*/
private $hash;
/**
* Path to storage.
*
* @var string
*/
private $storagePath;
// -------------------------------------------------------------------------
// ----| Attributes |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Return document's title, or url if empty.
*
* @return string
*/
public function getTitleAttribute()
{
if (!empty($this->attributes['title'])) {
return $this->attributes['title'];
}
return $this->url;
}
/**
* Return array of folders containing a bookmark to this document.
*
* @return array
*/
public function getDupplicatesAttribute()
{
return $this->dupplicates;
}
/**
* Return full URL to favicon.
*
* @return string
*/
public function getFaviconAttribute()
{
if (empty($this->attributes['favicon_path']) || !Storage::exists($this->attributes['favicon_path'])) {
if ($this->mimetype) {
$filename = str_replace('/', '-', $this->mimetype);
$path = sprintf('images/icons/mimetypes/%s.svg', $filename);
if (file_exists(realpath(public_path($path)))) {
return asset($path);
}
}
return asset('images/icons/mimetypes/unknown.svg');
}
return Storage::url($this->attributes['favicon_path']);
}
public function getHttpStatusTextAttribute()
{
if (!empty($this->attributes['http_status_text'])) {
return $this->attributes['http_status_text'];
}
if (empty($this->http_status_code)) {
if (empty($this->checked_at)) {
return __('Cyca did not check this document yet');
}
return __('Cyca could not reach this document URL');
}
}
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Bookmarks referencing this document.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function bookmark()
{
return $this->belongsToMany(Document::class, 'bookmarks')->using(Bookmark::class)->as('bookmark')->withPivot(['initial_url', 'created_at', 'updated_at', 'visits']);
}
/**
* Folders referencing this document.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function folders()
{
return $this->belongsToMany(Folder::class, 'bookmarks');
}
/**
* Feeds referenced by this document.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function feeds()
{
return $this->belongsToMany(Feed::class, 'document_feeds');
}
/**
* Associated feed items states.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function feedItemStates()
{
return $this->hasMany(FeedItemState::class);
}
// -------------------------------------------------------------------------
// ----| Scopes |-----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Scope a query to only include documents than were updated before specifed
* date.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param mixed $date
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeNeedingUpdate($query, $date)
{
return $query->where('checked_at', '<', $date)->orWhereNull('checked_at');
}
// -------------------------------------------------------------------------
// ----| Methods |----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Find dupplicates of this document in specified user's folders.
*/
public function findDupplicatesFor(User $user)
{
$ids = $user->documents()->where('document_id', $this->id)->select('folder_id')->pluck('folder_id');
$folders = Folder::find($ids);
foreach ($folders as $folder) {
$this->dupplicates[] = [
'id' => $folder->id,
'group_id' => $folder->group->id,
'breadcrumbs' => $folder->breadcrumbs,
];
}
return $this->dupplicates;
}
/**
* Build a hash for document's URL. Used to build path for storing assets
* related to this document. It doesn't need to provide a "secure" hash like
* for a password, so we're just going to use md5.
*
* The purpose of this hash is multiple:
*
* - Maximum number of folders in each level is 16, and hierarchy is 32
* folders deep, so it can be handled by any file system without problem
* - As it is based on document's URL and date of creation in Cyca, files
* cannot be "stolen" by direct access (Cyca couldn't and shouldn't be used
* as a favicon repository used by everyone, for instance)
* - It avoids issues with intl domain names or special chars in URLs
* - On the other side, it would be easy for Cyca to quickly know where to
* store assets for that particular document, and we can store all assets
* related to that document in the same folder
*
* @return string
*/
public function getHash()
{
if (empty($this->hash)) {
$this->hash = md5($this->url . $this->created_at);
}
return $this->hash;
}
/**
* Return path to root folder for storing this document's assets. This path
* can then be used to store and retrieve files using the Storage facade, so
* it does not return the full path of a directory rather than the path
* related to configured storage disk.
*
* @return string
*/
public function getStoragePath()
{
if (empty($this->storagePath)) {
$hash = $this->getHash();
$this->storagePath = 'public/documents/' . implode('/', str_split(($hash)));
}
return $this->storagePath;
}
/**
* Return a boolean value indicating if this document still belongs to any
* folder.
*
* @return true
*/
public function isOrphan()
{
return $this->folders()->count() === 0;
}
/**
* Return a boolean value indicating if this document was orphan for
* specified days.
*
* @return true
*/
public function wasOrphanFor(int $days)
{
return !empty($this->checked_at) && $this->checked_at->addDays($days)->lt(now());
}
}

248
app/Models/Feed.php Executable file
View File

@@ -0,0 +1,248 @@
<?php
namespace App\Models;
use App\Models\Traits\Feed\AnalysesFeed;
use App\Models\Traits\HasUrl;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class Feed extends Model
{
use AnalysesFeed;
use HasUrl;
// -------------------------------------------------------------------------
// ----| Properties |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'url',
];
/**
* The accessors to append to the model's array form.
*
* @var array
*/
protected $appends = [
'favicon',
'is_ignored',
'ascii_url',
];
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = [
'checked_at',
];
/**
* Hash of URL.
*
* @var string
*/
private $hash;
/**
* Path to storage.
*
* @var string
*/
private $storagePath;
// -------------------------------------------------------------------------
// ----| Attributes |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Return feed's title, or url if empty.
*
* @return string
*/
public function getTitleAttribute()
{
if (!empty($this->attributes['title'])) {
return $this->attributes['title'];
}
return $this->url;
}
/**
* Return full URL to favicon.
*
* @return string
*/
public function getFaviconAttribute()
{
if (!empty($this->attributes['favicon_path'])) {
return Storage::url($this->attributes['favicon_path']);
}
$document = $this->documents()->first();
if ($document) {
return $document->favicon;
}
return null;
}
/**
* Return a boolean value indicating if auth'ed user has ignored this feed.
*
* @return bool
*/
public function getIsIgnoredAttribute()
{
if (auth()->user()) {
return $this->ignored->firstWhere('user_id', auth()->user()->id) !== null;
}
return false;
}
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Documents referenced by this feed.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function documents()
{
return $this->belongsToMany(Document::class, 'document_feeds');
}
/**
* Feed items referenced by this feed.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function feedItems()
{
return $this->belongsToMany(FeedItem::class, 'feed_feed_items');
}
/**
* Associated unread feed items.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function feedItemStates()
{
return $this->hasMany(FeedItemState::class);
}
/**
* Users ignoring this feed.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function ignored()
{
return $this->hasMany(IgnoredFeed::class);
}
// -------------------------------------------------------------------------
// ----| Scopes |-----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Scope a query to only include feeds than were updated before specifed
* date.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param mixed $date
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeNeedingUpdate($query, $date)
{
return $query->where('checked_at', '<', $date)->orWhereNull('checked_at');
}
// -------------------------------------------------------------------------
// ----| Methods |----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Build a hash for document's URL. Used to build path for storing assets
* related to this document. It doesn't need to provide a "secure" hash like
* for a password, so we're just going to use md5.
*
* The purpose of this hash is multiple:
*
* - Maximum number of folders in each level is 16, and hierarchy is 32
* folders deep, so it can be handled by any file system without problem
* - As it is based on document's URL and date of creation in Cyca, files
* cannot be "stolen" by direct access (Cyca couldn't and shouldn't be used
* as a favicon repository used by everyone, for instance)
* - It avoids issues with intl domain names or special chars in URLs
* - On the other side, it would be easy for Cyca to quickly know where to
* store assets for that particular document, and we can store all assets
* related to that document in the same folder
*
* @return string
*/
public function getHash()
{
if (empty($this->hash)) {
$this->hash = md5($this->url . $this->created_at);
}
return $this->hash;
}
/**
* Return path to root folder for storing this document's assets. This path
* can then be used to store and retrieve files using the Storage facade, so
* it does not return the full path of a directory rather than the path
* related to configured storage disk.
*
* @return string
*/
public function getStoragePath()
{
if (empty($this->storagePath)) {
$hash = $this->getHash();
$this->storagePath = 'public/feeds/' . implode('/', str_split(($hash)));
}
return $this->storagePath;
}
/**
* Return a boolean value indicating if this feed still belongs to any
* document.
*
* @return true
*/
public function isOrphan()
{
return $this->documents()->count() === 0;
}
/**
* Return a boolean value indicating if this feed was orphan for
* specified days.
*
* @return true
*/
public function wasOrphanFor(int $days)
{
return !empty($this->checked_at) && $this->checked_at->addDays($days)->lt(now());
}
}

157
app/Models/FeedItem.php Executable file
View File

@@ -0,0 +1,157 @@
<?php
namespace App\Models;
use App\Models\Traits\HasUrl;
use Illuminate\Database\Eloquent\Model;
class FeedItem extends Model
{
use HasUrl;
// -------------------------------------------------------------------------
// ----| Properties |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* The attributes that should be mutated to dates.
*
* @var array
*/
protected $dates = [
'published_at',
];
/**
* The accessors to append to the model's array form.
*
* @var array
*/
protected $appends = [
'ascii_url',
];
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Feeds referenced by this item.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function feeds()
{
return $this->belongsToMany(Feed::class, 'feed_feed_items');
}
/**
* Associated feed item state.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function feedItemStates()
{
return $this->hasMany(FeedItemState::class);
}
// -------------------------------------------------------------------------
// ----| Scopes |-----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Scope a query to only include feed items read by all users.
*
* @param \Illuminate\Database\Eloquent\Builder $query
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeAllRead($query)
{
return $query->whereDoesntHave('feedItemStates', function ($subQuery) {
$subQuery->where('is_read', false);
});
}
/**
* Scope a query to only include feed items read by all users.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param mixed $date
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeOlderThan($query, $date)
{
return $query->where('published_at', '<', $date)
->orWhereNull('published_at');
}
/**
* Scope a query to only include feed items associated with specified feeds.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param array $feeds
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeInFeeds($query, $feeds)
{
return $query->whereHas('feeds', function ($subQuery) use ($feeds) {
$subQuery->whereIn('feeds.id', $feeds);
});
}
/**
* Scope a query to only include unread feed items for specified user.
*
* @param \Illuminate\Database\Eloquent\Builder $query
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeUnreadFor($query, User $user)
{
return $query->whereHas('feedItemStates', function ($subQuery) use ($user) {
$subQuery->where('user_id', $user->id)->where('is_read', false);
});
}
/**
* Scope a query to only include unread feed items for specified user.
*
* @param \Illuminate\Database\Eloquent\Builder $query
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeCountStates($query, User $user, bool $read = false)
{
return $query->withCount([
'feedItemStates' => function ($subQuery) use ($user, $read) {
$subQuery->where('user_id', $user->id)->where('is_read', $read);
},
]);
}
// -------------------------------------------------------------------------
// ----| Methods |----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Return path to root folder for storing this document's assets. This path
* can then be used to store and retrieve files using the Storage facade, so
* it does not return the full path of a directory rather than the path
* related to configured storage disk.
*
* @return string
*/
public function getStoragePath()
{
if (empty($this->storagePath)) {
$hash = $this->hash;
$this->storagePath = 'public/feeditems/'.implode('/', str_split(($hash)));
}
return $this->storagePath;
}
}

62
app/Models/FeedItemState.php Executable file
View File

@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class FeedItemState extends Model
{
// -------------------------------------------------------------------------
// ----| Properties |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* The attributes that are mass assignable.
*
* @var array
*/
public $fillable = [
'user_id',
'document_id',
'feed_id',
'feed_item_id',
];
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Associated groups.
*
* @return \Illuminate\Database\Eloquent\Relations\hasManyThrough
*/
public function groups()
{
return $this->hasManyThrough(Group::class, Folder::class);
}
/**
* Associated document.
*/
public function document()
{
return $this->belongsTo(Document::class);
}
// -------------------------------------------------------------------------
// ----| Scopes |-----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Scope a query to only include unread items.
*
* @param \Illuminate\Database\Eloquent\Builder $query
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeUnread($query)
{
return $query->where('is_read', false);
}
}

403
app/Models/Folder.php Executable file
View File

@@ -0,0 +1,403 @@
<?php
namespace App\Models;
use App\Models\Traits\Folder\BuildsTree;
use App\Models\Traits\Folder\CreatesDefaultFolders;
use Illuminate\Database\Eloquent\Model;
class Folder extends Model
{
use BuildsTree;
use CreatesDefaultFolders;
// -------------------------------------------------------------------------
// ----| Properties |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'type',
'title',
'parent_id',
'position',
'user_id',
'group_id',
];
/**
* The accessors to append to the model's array form.
*
* @var array
*/
protected $appends = [
'icon',
'iconColor',
'is_selected',
'is_expanded',
];
// -------------------------------------------------------------------------
// ----| Attributes |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Return folder's title.
*
* If it's a special folder, ie not created by user, we will automatically
* translate its original title.
*
* @return string
*/
public function getTitleAttribute()
{
switch ($this->type) {
// Unspecified type of folder
default:
return $this->attributes['title'];
// Unread items
case 'unread_items':
return __('Unread items');
// Root folder
case 'root':
return __('Root');
}
}
/**
* Return folder's icon as a fragment identifier for a SVG sprite.
*
* @return string
*/
public function getIconAttribute()
{
switch ($this->type) {
// Unspecified type of folder
default:
return 'folder';
// Unread items
case 'unread_items':
return 'unread_items';
// Root folder
case 'root':
return 'house';
}
}
/**
* Return icon's color as a CSS class.
*
* @return string
*/
public function getIconColorAttribute()
{
switch ($this->type) {
// Unspecified type of folder
default:
return 'folder-common';
// Unread items
case 'unread_items':
if ($this->feed_item_states_count > 0) {
return 'folder-unread-not-empty';
}
return 'folder-unread';
// Root folder
case 'root':
return 'folder-root';
}
}
/**
* Return a formatted path to the folder, using every ascendant's title.
*
* @return string
*/
public function getBreadcrumbsAttribute()
{
$parts = [
(string) view('partials.folder', ['folder' => $this]),
];
$parent = $this->parent;
while ($parent !== null) {
$parts[] = (string) view('partials.folder', ['folder' => $parent]);
$parent = $parent->parent;
}
$parts[] = (string) view('partials.group', ['group' => $this->group]);
return implode(' ', array_reverse($parts));
}
/**
* Return a boolean value indicating if this folder is selected by current
* user.
*
* @return bool
*/
public function getIsSelectedAttribute()
{
if (!auth()->check()) {
return false;
}
return auth()->user()->selectedFolder()->id === $this->id;
}
/**
* Return a boolean value indicating if folder is expanded for specified
* user.
*
* @return bool
*/
public function getIsExpandedAttribute()
{
if (!auth()->check()) {
return false;
}
return auth()->user()->getFolderExpandedState($this);
}
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Parent folder.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function parent()
{
return $this->belongsTo(Folder::class, 'parent_id');
}
/**
* Children folders.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function children()
{
return $this->hasMany(Folder::class, 'parent_id');
}
/**
* Creator of this folder.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Group this folder belongs to.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function group()
{
return $this->belongsTo(Group::class);
}
/**
* Documents in this folder.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function documents()
{
return $this->belongsToMany(Document::class, 'bookmarks')->using(Bookmark::class)->as('bookmark')->withPivot(['initial_url', 'created_at', 'updated_at', 'visits']);
}
/**
* Associated unread feed items.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function feedItemStates()
{
return $this->hasManyThrough(FeedItemState::class, Bookmark::class, 'folder_id', 'document_id', 'id', 'document_id');
}
/**
* Folder's permissions.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function permissions()
{
return $this->hasMany(Permission::class);
}
// -------------------------------------------------------------------------
// ----| Scopes |-----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Scope a query to only include folders of a given type.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param mixed $type
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeOfType($query, $type)
{
return $query->where('type', $type);
}
// -------------------------------------------------------------------------
// ----| Methods |----------------------------------------------------------
// -------------------------------------------------------------------------
public static function listDocumentIds($folders, $group)
{
$unreadItemsFolder = $group->folders()->ofType('unread_items')->first();
$query = $group->folders()->with('documents:id');
if (!in_array($unreadItemsFolder->id, $folders)) {
$query = $query->whereIn('id', $folders);
}
return $query->get()->pluck('documents')->flatten()->pluck('id')->unique();
}
/**
* Return a list of ids of documents present in this folder.
*
* @return array
*/
public function getDocumentIds()
{
return $this->documents()->select('documents.id')->pluck('documents.id');
}
/**
* Return a list of documents built for front-end for specified user.
*
* @param \App\Models\User $user
*
* @return \Illuminate\Support\Collection
*/
public function listDocuments(User $user)
{
$columns = [
'documents.id',
'documents.url',
'documents.http_status_code',
'documents.http_status_text',
'documents.mimetype',
'documents.title',
'documents.favicon_path',
'documents.checked_at',
];
if ($this->type === 'unread_items') {
$documents = $this->group->documents()->pluck('document_id');
return Document::select($columns)->with('feeds:feeds.id', 'feeds.ignored', 'bookmark')->withCount(['feedItemStates' => function ($query) use ($user) {
$query->where('is_read', false)->where('user_id', $user->id);
}])->whereHas('feedItemStates', function ($query) use ($user) {
$query->where('is_read', false)->where('user_id', $user->id);
})->whereIn('documents.id', $documents)
->get();
} else {
$documentIds = $this->getDocumentIds();
return Document::select($columns)->with('feeds:feeds.id', 'feeds.ignored', 'bookmark')->withCount(['feedItemStates' => function ($query) use ($user) {
$query->where('is_read', false)->where('user_id', $user->id);
}])->whereIn('documents.id', $documentIds)
->get();
}
}
/**
* Return folder's permissions that applies to any user without explicit
* permissions.
*
* @return array
*/
public function getDefaultPermissions()
{
$defaultPermissions = $this->permissions()->whereNull('user_id')->first();
if (empty($defaultPermissions)) {
return [
'can_create_folder' => false,
'can_update_folder' => false,
'can_delete_folder' => false,
'can_create_document' => false,
'can_delete_document' => false,
];
}
return [
'can_create_folder' => $defaultPermissions->can_create_folder,
'can_update_folder' => $defaultPermissions->can_update_folder,
'can_delete_folder' => $defaultPermissions->can_delete_folder,
'can_create_document' => $defaultPermissions->can_create_document,
'can_delete_document' => $defaultPermissions->can_delete_document,
];
}
/**
* Return user's permission specific to this folder.
*
* @return array
*/
public function getUserPermissions(User $user = null)
{
if (empty($user)) {
if (!auth()->check()) {
return null;
}
$user = auth()->user();
}
return [
'can_change_permissions' => $this->group->user_id === $user->id,
'can_create_folder' => $user->can('createIn', $this),
'can_update_folder' => $user->can('update', $this),
'can_delete_folder' => $user->can('delete', $this),
'can_create_document' => $user->can('createBookmarkIn', $this),
'can_delete_document' => $user->can('deleteBookmarkFrom', $this),
];
}
public function setDefaultPermission($ability = null, $granted = false)
{
$permissions = $this->permissions()->whereNull('user_id')->first();
if (!$permissions) {
$permissions = new Permission();
$permissions->folder()->associate($this);
$permissions->can_create_folder = false;
$permissions->can_update_folder = false;
$permissions->can_delete_folder = false;
$permissions->can_create_document = false;
$permissions->can_delete_document = false;
}
if ($ability) {
$permissions->{$ability} = $granted;
}
$permissions->save();
return $permissions;
}
}

265
app/Models/Group.php Executable file
View File

@@ -0,0 +1,265 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Group extends Model
{
use HasFactory;
// -------------------------------------------------------------------------
// ----| Constants |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Group is owned by related user.
*/
public static $STATUS_OWN = 'own';
/**
* Group was created by related user.
*/
public static $STATUS_CREATED = 'created';
/**
* User has been invited in the group.
*/
public static $STATUS_INVITED = 'invited';
/**
* User accepted to join the group.
*/
public static $STATUS_ACCEPTED = 'accepted';
/**
* User declined joining the group.
*/
public static $STATUS_REJECTED = 'rejected';
/**
* User asked to join a group.
*/
public static $STATUS_JOINING = 'joining';
/**
* User has left the group.
*/
public static $STATUS_LEFT = 'left';
// -------------------------------------------------------------------------
// ----| Properties |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'description',
'user_id',
'invite_only',
'auto_accept_users',
];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'invite_only' => 'boolean',
'auto_accept_users' => 'boolean',
'feed_item_states_count' => 'integer',
];
/**
* The accessors to append to the model's array form.
*
* @var array
*/
protected $appends = [
'is_selected',
'feed_item_states_count',
];
protected $feedItemStatesCount;
// -------------------------------------------------------------------------
// ----| Attributes |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Return a boolean value indicating if this group is selected by current
* user.
*
* @return bool
*/
public function getIsSelectedAttribute()
{
if (!auth()->check()) {
return false;
}
return auth()->user()->selectedGroup()->id === $this->id;
}
/**
* Return the number of unread feed items for this group and current user.
*
* @return int
*/
public function getFeedItemStatesCountAttribute()
{
if (!auth()->check()) {
return 0;
}
if ($this->feedItemStatesCount !== null) {
return $this->feedItemStatesCount;
}
return $this->getUnreadFeedItemsCountFor(auth()->user());
}
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Creator of the group.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function creator()
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Associated users.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function users()
{
return $this->belongsToMany(User::class, 'user_groups')->withPivot(['status']);
}
/**
* Associated users.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function activeUsers()
{
return $this->belongsToMany(User::class, 'user_groups')->withPivot(['status'])->whereIn('status', [
self::$STATUS_OWN,
self::$STATUS_CREATED,
self::$STATUS_ACCEPTED,
]);
}
/**
* Associated users in a pending state (either invited or asking to join,
* without an answer yet).
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function pendingUsers()
{
return $this->belongsToMany(User::class, 'user_groups')->withPivot(['status'])->whereIn('status', [
self::$STATUS_INVITED,
self::$STATUS_JOINING,
]);
}
/**
* Associated folders.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function folders()
{
return $this->hasMany(Folder::class);
}
/**
* Associated bookmarks.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function documents()
{
return $this->hasManyThrough(Bookmark::class, Folder::class);
}
// -------------------------------------------------------------------------
// ----| Scopes |-----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Scope a query to only include user's own group.
*
* @param \Illuminate\Database\Eloquent\Builder $query
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeOwn($query)
{
return $query->where('status', self::$STATUS_OWN);
}
/**
* Scope a query to only include groups the user is active.
*
* @param \Illuminate\Database\Eloquent\Builder $query
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeActive($query)
{
return $query->whereIn('status', [
self::$STATUS_OWN,
self::$STATUS_CREATED,
self::$STATUS_ACCEPTED,
]);
}
/**
* Scope a query to only include visible groups.
*
* @param \Illuminate\Database\Eloquent\Builder $query
*
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeVisible($query)
{
return $query->where('invite_only', false);
}
// -------------------------------------------------------------------------
// ----| Methods |----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Create default folders for this group.
*/
public function createDefaultFolders()
{
Folder::createDefaultFoldersFor($this->creator, $this);
}
public function getUnreadFeedItemsCountFor(User $user)
{
$documentsIds = $this->documents()->pluck('document_id')->unique();
$this->feedItemStatesCount = FeedItemState::whereIn('document_id', $documentsIds)
->where('is_read', false)->where('user_id', $user->id)->count();
return $this->feedItemStatesCount;
}
}

16
app/Models/Highlight.php Executable file
View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Highlight extends Model
{
use HasFactory;
public function user()
{
return $this->belongsTo(User::class);
}
}

34
app/Models/IgnoredFeed.php Executable file
View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class IgnoredFeed extends Model
{
public $timestamps = false;
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Ignored feed.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function feed()
{
return $this->belongsTo(Feed::class);
}
/**
* User ignoring feed.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Models\Observers;
use App\Models\Bookmark;
use App\Notifications\UnreadItemsChanged;
use Illuminate\Support\Facades\Notification;
class BookmarkObserver
{
/**
* Handle the bookmark "created" event.
*/
public function created(Bookmark $bookmark)
{
Notification::send($bookmark->folder->group->activeUsers, new UnreadItemsChanged(['documents' => [$bookmark->document->id]]));
}
/**
* Handle the bookmark "deleting" event.
*/
public function deleting(Bookmark $bookmark)
{
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models\Observers;
use App\Jobs\EnqueueDocumentUpdate;
use App\Models\Document;
use App\Notifications\DocumentUpdated;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
class DocumentObserver
{
/**
* Handle the document "created" event.
*/
public function created(Document $document)
{
EnqueueDocumentUpdate::dispatch($document);
}
/**
* Handle the document "updated" event.
*/
public function updated(Document $document)
{
$usersToNotify = [];
foreach ($document->folders()->with('user')->get() as $folder) {
$usersToNotify[] = $folder->user;
}
Notification::send($usersToNotify, new DocumentUpdated($document));
}
/**
* Handle the document "deleted" event.
*/
public function deleted(Document $document)
{
Storage::deleteDirectory($document->getStoragePath());
}
/**
* Handle the document "restored" event.
*/
public function restored(Document $document)
{
}
/**
* Handle the document "force deleted" event.
*/
public function forceDeleted(Document $document)
{
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Models\Observers;
use App\Models\FeedItem;
use Illuminate\Support\Facades\Storage;
class FeedItemObserver
{
/**
* Handle the feed item "deleted" event.
*/
public function deleted(FeedItem $feedItem)
{
Storage::deleteDirectory($feedItem->getStoragePath());
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models\Observers;
use App\Jobs\EnqueueFeedUpdate;
use App\Models\Feed;
class FeedObserver
{
/**
* Handle the feed "created" event.
*/
public function created(Feed $feed)
{
EnqueueFeedUpdate::dispatch($feed);
}
/**
* Handle the feed "updated" event.
*/
public function updated(Feed $feed)
{
}
/**
* Handle the feed "deleted" event.
*/
public function deleted(Feed $feed)
{
}
/**
* Handle the feed "restored" event.
*/
public function restored(Feed $feed)
{
}
/**
* Handle the feed "force deleted" event.
*/
public function forceDeleted(Feed $feed)
{
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models\Observers;
use App\Models\Folder;
use App\Notifications\UnreadItemsChanged;
use Illuminate\Support\Facades\Notification;
class FolderObserver
{
/**
* Handle the folder "created" event.
*/
public function created(Folder $folder)
{
}
/**
* Handle the folder "deleting" event.
*/
public function deleting(Folder $folder)
{
}
/**
* Handle the folder "deleted" event.
*/
public function deleted(Folder $folder)
{
Notification::send($folder->group->activeUsers, new UnreadItemsChanged(['folders' => [$folder]]));
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models\Observers;
use App\Models\Group;
class GroupObserver
{
/**
* Handle the group "created" event.
*/
public function created(Group $group)
{
$group->createDefaultFolders();
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models\Observers;
use App\Models\IgnoredFeed;
class IgnoredFeedObserver
{
/**
* Handle the ignored feed "created" event.
*/
public function created(IgnoredFeed $ignoredFeed)
{
}
/**
* Handle the ignored feed "deleting" event.
*/
public function deleting(IgnoredFeed $ignoredFeed)
{
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models\Observers;
use App\Models\User;
class UserObserver
{
/**
* Handle the user "created" event.
*/
public function created(User $user)
{
$group = $user->createOwnGroup();
$user->importInitialData($group);
}
}

65
app/Models/Permission.php Executable file
View File

@@ -0,0 +1,65 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Permission extends Model
{
use HasFactory;
// -------------------------------------------------------------------------
// ----| Properties |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* The attributes that are mass assignable.
*
* @var array
*/
public $fillable = [
'can_create_folder',
'can_update_folder',
'can_delete_folder',
'can_create_document',
'can_delete_document',
];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
public $casts = [
'can_create_folder' => 'boolean',
'can_update_folder' => 'boolean',
'can_delete_folder' => 'boolean',
'can_create_document' => 'boolean',
'can_delete_document' => 'boolean',
];
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Related user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Related folder.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function folder()
{
return $this->belongsTo(Folder::class);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Models\Policies;
use App\Models\Document;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class DocumentPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*
* @return mixed
*/
public function viewAny(User $user)
{
}
/**
* Determine whether the user can view the model.
*
* @return mixed
*/
public function view(User $user, Document $document)
{
return true;
}
/**
* Determine whether the user can create models.
*
* @return mixed
*/
public function create(User $user)
{
// Authorization will be checked in target folder
return true;
}
/**
* Determine whether the user can update the model.
*
* @return mixed
*/
public function update(User $user, Document $document)
{
}
/**
* Determine whether the user can delete the model.
*
* @return mixed
*/
public function delete(User $user, Document $document)
{
}
/**
* Determine whether the user can restore the model.
*
* @return mixed
*/
public function restore(User $user, Document $document)
{
}
/**
* Determine whether the user can permanently delete the model.
*
* @return mixed
*/
public function forceDelete(User $user, Document $document)
{
}
}

View File

@@ -0,0 +1,211 @@
<?php
namespace App\Models\Policies;
use App\Models\Folder;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class FolderPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*
* @return mixed
*/
public function viewAny(User $user)
{
return false;
}
/**
* Determine whether the user can view the model.
*
* @return mixed
*/
public function view(User $user, Folder $folder)
{
return $this->checkFolderAuthorization($user, $folder);
}
/**
* Determine whether the user can create models.
*
* @return mixed
*/
public function create(User $user)
{
// We will perform real validation in the createIn method below through
// the Folder/StoreRequest FormRequest as we need the folder we're
// trying to create a sub-folder to
return true;
}
/**
* Determine whether the user can create models.
*
* @return mixed
*/
public function createIn(User $user, Folder $folder)
{
if ($folder->type === 'unread_items') {
return false;
}
return $this->checkFolderAuthorization($user, $folder, 'can_create_folder');
}
/**
* Determine whether the user can create a bookmark in specified folder.
*
* @return mixed
*/
public function createBookmarkIn(User $user, Folder $folder)
{
if ($folder->type === 'unread_items') {
return false;
}
return $this->checkFolderAuthorization($user, $folder, 'can_create_document');
}
/**
* Determine whether the user can remove a bookmark from specified folder.
*
* @return mixed
*/
public function deleteBookmarkFrom(User $user, Folder $folder)
{
if ($folder->type === 'unread_items') {
return false;
}
return $this->checkFolderAuthorization($user, $folder, 'can_delete_document');
}
/**
* Determine whether the user can update the model.
*
* @return mixed
*/
public function update(User $user, Folder $folder)
{
return $this->checkFolderAuthorization($user, $folder, 'can_update_folder');
}
/**
* Determine whether the user can delete the model.
*
* @return mixed
*/
public function delete(User $user, Folder $folder)
{
if ($folder->type !== 'folder') {
return false;
}
return $this->checkFolderAuthorization($user, $folder, 'can_delete_folder');
}
/**
* Determine whether the user can restore the model.
*
* @return mixed
*/
public function restore(User $user, Folder $folder)
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*
* @return mixed
*/
public function forceDelete(User $user, Folder $folder)
{
return false;
}
/**
* Determine whether the user can update model's permissions.
*
* @return mixed
*/
public function setPermission(User $user, Folder $folder)
{
return $folder->group->user_id === $user->id;
}
/**
* Check if specified user is the creator of specified folder.
*
* @return bool
*/
private function hasCreatedFolder(User $user, Folder $folder)
{
return (int) $folder->user_id === (int) $user->id;
}
/**
* Return a boolean value indicating if specified user has created the group
* specified folder belongs to.
*/
private function userCreatedFolderGroup(User $user, Folder $folder)
{
$group = $this->folderBelongsToActiveUserGroup($user, $folder);
if (!empty($group)) {
return $group->user_id === $user->id;
}
return false;
}
/**
* Perform common authorization tests for specified user and folder.
*
* @return array
*/
private function checkFolderAuthorization(User $user, Folder $folder, string $ability = null)
{
if ($this->hasCreatedFolder($user, $folder)) {
return true;
}
if ($this->userCreatedFolderGroup($user, $folder)) {
return true;
}
$permissions = $folder->permissions()->where('user_id', $user->id)->first();
if (!$permissions) {
$defaultPermissions = $folder->permissions()->whereNull('user_id')->first();
if (empty($defaultPermissions)) {
$defaultPermissions = $folder->setDefaultPermission();
}
$permissions = $defaultPermissions;
}
if ($ability) {
return $permissions->{$ability};
}
return false;
}
/**
* Determine if specified folder belongs to a group in which specified user
* is active.
*
* @return \App\Models\Group
*/
private function folderBelongsToActiveUserGroup(User $user, Folder $folder)
{
return $user->groups()->active()->find($folder->group_id);
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Models\Policies;
use App\Models\Group;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class GroupPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*
* @return mixed
*/
public function viewAny(User $user)
{
return true;
}
/**
* Determine whether the user can view the model.
*
* @return mixed
*/
public function view(User $user, Group $group)
{
return $this->checkGroupAuthorization($user, $group, [
Group::$STATUS_ACCEPTED,
]);
}
/**
* Determine whether the user can create models.
*
* @return mixed
*/
public function create(User $user)
{
return true;
}
/**
* Determine whether the user can update the model.
*
* @return mixed
*/
public function update(User $user, Group $group)
{
return $group->user_id === $user->id;
}
/**
* Determine whether the user can invite someone into specified group.
*
* @return mixed
*/
public function invite(User $user, Group $group)
{
return $this->checkGroupAuthorization($user, $group, [
Group::$STATUS_OWN,
Group::$STATUS_CREATED,
]);
}
/**
* Determine whether the user can approve someone to join specified group.
*
* @return mixed
*/
public function approve(User $user, Group $group)
{
return $this->checkGroupAuthorization($user, $group, [
Group::$STATUS_OWN,
Group::$STATUS_CREATED,
]);
}
/**
* Determine whether the user can delete the model.
*
* @return mixed
*/
public function delete(User $user, Group $group)
{
return $group->user_id === $user->id;
}
/**
* Determine whether the user can restore the model.
*
* @return mixed
*/
public function restore(User $user, Group $group)
{
}
/**
* Determine whether the user can permanently delete the model.
*
* @return mixed
*/
public function forceDelete(User $user, Group $group)
{
}
/**
* Perform common authorization tests for specified user and group.
*
* @param mixed $statuses
*
* @return bool
*/
private function checkGroupAuthorization(User $user, Group $group, $statuses = [])
{
// Specified user is group's creator
if ($group->user_id === $user->id) {
return true;
}
$userGroup = $user->groups()->active()->find($group->id);
if (!$userGroup) {
return false;
}
if (!empty($statuses) && $userGroup->pivot && in_array($userGroup->pivot->status, $statuses)) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,226 @@
<?php
namespace App\Models\Traits\Document;
use App\Models\Bookmark;
use App\Models\Feed;
use Illuminate\Support\Facades\Http;
use SimplePie;
use Storage;
trait AnalysesDocument
{
// -------------------------------------------------------------------------
// ----| Properties |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Provides temporary access to response to analyzers.
*
* @var \Illuminate\Http\Client\Response
*/
protected $response;
/**
* Provides temporary access to document's body to analyzers.
*
* @var string
*/
private $body;
// -------------------------------------------------------------------------
// ----| Methods |----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Begin document analysis.
*/
public function analyze()
{
// Don't bother if document isn't bookmarked anymore
if ($this->isOrphan() && $this->wasOrphanFor(config('cyca.maxOrphanAge.document'))) {
$this->delete();
return;
}
$this->fetchContent();
if (empty($this->response)) {
$this->checked_at = now();
$this->save();
return;
}
if ($homepage = $this->isFeed()) {
$this->convertToFeed($homepage);
return;
}
if ($this->existingDocumentsMerged()) {
return;
}
$this->runAnalyzers();
$this->checked_at = now();
$this->save();
}
/**
* Copy content of resource at document's URL in "local" storage.
*/
protected function fetchContent()
{
$storageRoot = $this->getStoragePath();
$bodyFilename = $storageRoot.'/body';
$responseFilename = $storageRoot.'/response.json';
$debugFilename = $storageRoot.'/debug';
Storage::put($debugFilename, null);
# $debugStream = fopen(storage_path('app/'.$debugFilename), 'w');
#
try {
$this->response = Http::withOptions(array_merge([
# 'debug' => env('APP_DEBUG') ? $debugStream : false,
], config('http_client')))->timeout(30)->get($this->url);
$this->body = $this->response->body();
} catch (\Exception $ex) {
report($ex);
# } finally {
# fclose($debugStream);
}
if (!$this->response) {
return;
}
$psrResponse = $this->response->toPsrResponse();
$responseData = [
'headers' => $this->response->headers(),
'protocol_version' => $psrResponse->getProtocolVersion(),
'response' => $this->response,
];
Storage::put($responseFilename, json_encode($responseData));
if ($this->response->ok()) {
Storage::put($bodyFilename, $this->body);
$this->mimetype = Storage::mimetype($bodyFilename);
}
$this->http_status_code = $this->response->status();
$this->http_status_text = $this->response->getReasonPhrase();
}
/**
* Quickly determine if document is, in fact, a feed, and return
* corresponding home page URL.
*
* @return bool|string Return feed's home page if really a feed, false otherwise
*/
protected function isFeed()
{
$client = new SimplePie();
$client->enable_cache(false);
$client->set_raw_data($this->body);
if ($client->init()) {
return urldecode($client->get_permalink());
}
return false;
}
/**
* Transform this document into a feed, by creating or using an existing
* document with provided homepage URL, creating or using an existing feed
* with current document's URL, linking both, updating any references to
* this document to point to the new document, and finally deleting this
* document.
*
* @param string $homepage
*/
protected function convertToFeed($homepage)
{
$document = self::firstOrCreate(['url' => $homepage]);
$feed = Feed::firstOrCreate(['url' => $this->url]);
if (!$document->feeds()->find($feed->id)) {
$document->feeds()->attach($feed);
}
Bookmark::where('document_id', $this->id)->update(['document_id' => $document->id, 'initial_url' => $homepage]);
$this->delete();
}
/**
* Find another document having the same real URL. If one is found, we will
* update all bookmarks to use the oldest document and delete this one.
*
* Returns true if documents have been merged.
*
* @return bool
*/
protected function existingDocumentsMerged()
{
$realUrl = urldecode((string) $this->response->effectiveUri());
if ($realUrl !== $this->url) {
$document = self::where('url', $realUrl)->first();
if ($document) {
Bookmark::where('document_id', $this->id)->update(['document_id' => $document->id]);
$allBookmarks = Bookmark::where('document_id', $this->id)->get()->groupBy('folder_id');
foreach ($allBookmarks as $folderId => $bookmarks) {
if ($bookmarks->count() > 1) {
array_shift($bookmarks);
foreach ($bookmarks as $bookmark) {
$bookmark->delete();
}
}
}
return true;
}
$this->url = $realUrl;
}
return false;
}
/**
* Select analyzers for this particular document then run them.
*/
protected function runAnalyzers()
{
if (array_key_exists($this->mimetype, config('analyzers'))) {
$this->launchAnalyzerFor($this->mimetype);
} else {
// In doubt, launch HtmlAnalyzer
$this->launchAnalyzerFor('text/html');
}
}
protected function launchAnalyzerFor($mimetype)
{
$className = config(sprintf('analyzers.%s', $mimetype));
$instance = new $className();
$instance->setDocument($this)->setBody($this->body)->setResponse($this->response)->analyze();
}
}

View File

@@ -0,0 +1,222 @@
<?php
namespace App\Models\Traits\Feed;
use App\Models\FeedItem;
use App\Models\FeedItemState;
use App\Notifications\UnreadItemsChanged;
use DomDocument;
use DOMXPath;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
use SimplePie;
trait AnalysesFeed
{
// --------------------------------------------------------------------------
// ----| Properties |--------------------------------------------------------
// --------------------------------------------------------------------------
/**
* SimplePie client.
*
* @var SimplePie
*/
private $client;
// --------------------------------------------------------------------------
// ----| Methods |-----------------------------------------------------------
// --------------------------------------------------------------------------
/**
* Begin feed analysis.
*/
public function analyze()
{
// Don't bother if feed isn't attached to any document anymore
if ($this->isOrphan() && $this->wasOrphanFor(config('cyca.maxOrphanAge.feed'))) {
$this->delete();
return;
}
$this->prepareClient();
if (!$this->client->init()) {
$this->error = $this->client->error();
$this->checked_at = now();
$this->save();
return;
}
if ($this->client->subscribe_url() !== $this->url) {
$this->url = $this->client->subscribe_url();
}
$this->title = \App\Helpers\Cleaner::cleanupString($this->client->get_title(), true, true);
$this->description = \App\Helpers\Cleaner::cleanupString($this->client->get_description());
$this->checked_at = now();
$this->save();
$this->createItems($this->client->get_items());
}
/**
* Prepare the client.
*/
protected function prepareClient()
{
$this->client = new SimplePie();
Storage::disk('local')->makeDirectory($this->getStoragePath() . '/cache');
$this->client->force_feed(true);
$this->client->set_cache_location(storage_path('app/' . $this->getStoragePath() . '/cache'));
$this->client->set_feed_url($this->url);
}
/**
* Store feed items in database.
*
* @param array $items
*/
protected function createItems($items)
{
$toSync = $this->feedItems()->pluck('feed_items.id')->all();
$newItems = [];
foreach ($items as $item) {
$feedItem = FeedItem::where('hash', $item->get_id(true))->first();
if (!$feedItem) {
$feedItem = new FeedItem();
$feedItem->hash = $item->get_id(true);
$feedItem->title = \App\Helpers\Cleaner::cleanupString($item->get_title(), true, true);
$feedItem->url = $item->get_permalink();
$feedItem->description = $this->formatText($item->get_description(true));
$feedItem->content = $this->formatText($item->get_content(true));
$feedItem->published_at = $item->get_gmdate();
if (empty($feedItem->published_at)) {
$feedItem->published_at = now();
}
if ($feedItem->published_at->addDays(config('cyca.maxOrphanAge.feeditems'))->lt(now())) {
continue;
}
$feedItem->save();
$data = collect($item->data)->except([
'data',
'child',
]);
Storage::put($feedItem->getStoragePath() . '/data.json', $data->toJson());
}
if (!in_array($feedItem->id, $toSync)) {
$toSync[] = $feedItem->id;
}
$newItems[] = $feedItem;
}
$this->feedItems()->sync($toSync);
$this->createUnreadItems($newItems);
}
/**
* Apply various transformations to specified text.
*
* @param string $text
*
* @return string
*/
protected function formatText($text)
{
if (empty($text)) {
return;
}
$text = mb_convert_encoding($text, 'HTML-ENTITIES', 'UTF-8');
if (empty($text)) {
return;
}
libxml_use_internal_errors(true);
$domDocument = new DomDocument('1.0', 'UTF-8');
$domDocument->loadHtml($text, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
libxml_clear_errors();
$xpath = new DOMXPath($domDocument);
$anchors = $xpath->query('//a');
foreach ($anchors as $anchor) {
$anchor->setAttribute('rel', 'noopener noreferrer');
$anchor->setAttribute('href', urldecode($anchor->getAttribute('href')));
}
$text = $domDocument->saveHTML();
return \App\Helpers\Cleaner::cleanupString($text);
}
protected function createUnreadItems($feedItems)
{
$ignoredByUsers = $this->ignored()->pluck('user_id')->all();
$documentsChanged = [];
$foldersChanged = [];
$usersToNotify = [];
foreach ($this->documents()->get() as $document) {
$folders = $document->folders()->get();
foreach ($folders as $folder) {
if (!array_key_exists($folder->id, $foldersChanged)) {
$foldersChanged[$folder->id] = $folder;
}
$users = $folder->group->activeUsers()->whereNotIn('users.id', $ignoredByUsers)->get();
foreach ($users as $user) {
if (!array_key_exists($user->id, $usersToNotify)) {
$usersToNotify[$user->id] = $user;
}
foreach ($feedItems as $feedItem) {
$feedItemStateData = [
'document_id' => $document->id,
'feed_id' => $this->id,
'user_id' => $user->id,
'feed_item_id' => $feedItem->id,
];
$feedItemState = FeedItemState::where('user_id', $user->id)
->where('feed_item_id', $feedItem->id)
->first();
if (!$feedItemState) {
FeedItemState::create($feedItemStateData);
if (!in_array($document->id, $documentsChanged)) {
$documentsChanged[] = $document->id;
}
}
}
}
}
}
Notification::send($usersToNotify, new UnreadItemsChanged(['folders' => $foldersChanged, 'documents' => $documentsChanged]));
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Models\Traits\Folder;
use App\Models\Group;
use App\Models\User;
use Arr;
/**
* Constructs tree representation.
*/
trait BuildsTree
{
/**
* Return user's folders as a flat tree.
*
* @return \Illuminate\Support\Collection
*/
public static function getFlatTreeFor(User $user, Group $group)
{
$tree = [];
$query = $group->folders()
->select([
'id',
'parent_id',
'type',
'title',
'position',
'group_id',
])
->withCount(['children', 'feedItemStates' => function ($query) use ($user) {
$query->where('is_read', false)->where('user_id', $user->id);
}])
->orderBy('parent_id', 'asc')->orderBy('position', 'asc')
->orderBy('title', 'asc');
$folders = $query->get();
$roots = $folders->collect()->filter(function ($folder) {
return $folder->parent_id === null;
});
foreach ($roots as $root) {
if ($root->type === 'unread_items') {
$root->feed_item_states_count = $group->feedItemStatesCount;
}
$branch = self::buildBranch($root, $folders, 0);
$tree[] = $branch;
}
return collect(Arr::flatten($tree));
}
/**
* Construct a flat array of sub-folders for specified parent folder.
*
* @param \App\Models\Folder $folder Parent folder for the branch
* @param \Illuminate\Support\Collection $allFolders All folders associated to the same user as the parent folder
* @param int $depth Current depth
*
* @return array
*/
public static function buildBranch(self $folder, $allFolders, $depth)
{
$folder->depth = $depth;
$branch = [];
$branch[] = $folder;
$subFolders = $allFolders->collect()->where('parent_id', $folder->id);
foreach ($subFolders as $subFolder) {
$branch[] = self::buildBranch($subFolder, $allFolders, $depth + 1);
}
return $branch;
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models\Traits\Folder;
use App\Models\Group;
use App\Models\User;
trait CreatesDefaultFolders
{
// --------------------------------------------------------------------------
// ----| Constants |---------------------------------------------------------
// --------------------------------------------------------------------------
/**
* Position of the unread items folder in the folders hierarchy.
*/
private static $POSITION_UNREAD_ITEMS = 0;
/**
* Position of the root folder in the folders hierarchy.
*/
private static $POSITION_ROOT = 1;
// --------------------------------------------------------------------------
// ----| Methods |-----------------------------------------------------------
// --------------------------------------------------------------------------
/**
* Create default folders for specified group. This method should be called
* only once when group is created.
*
* @param \App\Models\User $user User creating the folders
*
* @throws \App\Exceptions\UserDoesNotExistsException
*/
public static function createDefaultFoldersFor(User $user, Group $group)
{
$group->folders()->saveMany([
new self([
'type' => 'unread_items',
'title' => 'Unread items',
'position' => self::$POSITION_UNREAD_ITEMS,
'user_id' => $user->id,
]),
new self([
'type' => 'root',
'title' => 'Root',
'position' => self::$POSITION_ROOT,
'user_id' => $user->id,
]),
]);
session([
sprintf('selectedFolder.%d', $group->id) => $group->folders()->ofType('root')->first()->id,
]);
}
}

50
app/Models/Traits/HasUrl.php Executable file
View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models\Traits;
trait HasUrl
{
/**
* Return url in its idn form. Adds HTML markup to "syntax highlight" url
* elements.
*
* @return string
*/
public function getAsciiUrlAttribute()
{
if (empty($this->attributes['url'])) {
return null;
}
mb_internal_encoding('UTF-8');
$url = \urldecode($this->attributes['url']);
$host = \parse_url($url, PHP_URL_HOST);
$ascii = \idn_to_ascii($host);
$idnUrl = str_replace($host, $ascii, $url);
$finalUrl = '';
foreach (preg_split('//u', $idnUrl, null, PREG_SPLIT_NO_EMPTY) as $char) {
if (mb_strlen($char) != strlen($char)) {
$class = 'suspicious';
} elseif (preg_match('#[A-Z]#', $char)) {
$class = 'capital';
} elseif (preg_match('#[a-z]#', $char)) {
$class = 'letter';
} elseif (preg_match('#[0-9]#', $char)) {
$class = 'number';
} elseif (preg_match('#([:/.?$\#_=])#', $char)) {
$class = 'operator';
} elseif (empty($char)) {
$class = 'empty';
} else {
$class = 'other';
}
$finalUrl .= sprintf('<span class="%s">%s</span>', $class, $char);
}
return $finalUrl;
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Models\Traits\User;
use App\Models\Document;
use App\Models\FeedItemState;
use App\Models\Folder;
use App\Models\Group;
trait HasFeeds
{
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Associated feed item state.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function feedItemStates()
{
return $this->hasMany(FeedItemState::class);
}
// -------------------------------------------------------------------------
// ----| Methods |----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Mark feed items as read in specified folders, as an array of folder ids.
*
* @param array $folders
*/
public function markFeedItemsReadInFolders($folders, Group $group = null)
{
if (empty($group)) {
$group = $this->selectedGroup();
}
$unreadItemsFolder = $group->folders()->ofType('unread_items')->first();
$query = $group->folders()->with('documents:documents.id');
if (!in_array($unreadItemsFolder->id, $folders)) {
$query = $query->whereIn('folders.id', $folders);
}
$folders = $query->get();
$documentIds = $query->get()->pluck('documents')->flatten()->pluck('id')->unique();
$query = $this->feedItemStates()->unread()->whereIn('document_id', $documentIds);
$feedItemIds = $query->pluck('feed_item_id')->unique();
$query->update(['is_read' => true]);
return $this->countUnreadItems([
'folders' => $folders,
'documents' => $documentIds,
'updated_feed_items' => $feedItemIds,
]);
}
/**
* Mark feed items as read in specified documents, as an array of document
* ids.
*
* @param array $documents
*/
public function markFeedItemsReadInDocuments($documents)
{
$query = $this->feedItemStates()->unread()->whereIn('document_id', $documents);
$feedItemIds = $query->pluck('feed_item_id')->unique();
$query->update(['is_read' => true]);
return $this->countUnreadItems([
'documents' => $documents,
'updated_feed_items' => $feedItemIds,
]);
}
/**
* Mark feed items as read in specified feeds, as an array of feed ids.
*
* @param array $feeds
*/
public function markFeedItemsReadInFeeds($feeds)
{
abort(404);
}
/**
* Mark specified feed items as read, as an array of feed item ids.
*
* @param array $feedItems
*/
public function markFeedItemsRead($feedItems)
{
$query = $this->feedItemStates()->unread()->whereIn('feed_item_id', $feedItems);
$feedItemIds = $query->pluck('feed_item_id')->unique();
$documentIds = $query->pluck('document_id')->unique();
$query->update(['is_read' => true]);
return $this->countUnreadItems([
'documents' => $documentIds,
'updated_feed_items' => $feedItemIds,
]);
}
/**
* Calculate unread items counts for current user. The $for array allows to
* be more specific by specifying ids for feed_items, documents or folder
* to re-count for in particular.
*
* This method returns an array containing the id of each document and
* folder along with corresponding unread items count, as well as a total
* of unread items count for each group and folders of type "unread_items".
*
* @param array $for
*
* @return array
*/
public function countUnreadItems($for)
{
$data = [];
if (empty($for['documents'])) {
if (!empty($for['folders'])) {
$for['documents'] = Folder::listDocumentIds(collect($for['folders'])->pluck('id')->all(), $this->selectedGroup());
}
}
$countPerDocument = $this->feedItemStates()->unread()->whereIn('document_id', $for['documents'])->get()->countBy('document_id')->all();
$countPerGroup = [];
if (!empty($for['documents'])) {
foreach ($for['documents'] as $id) {
if (!array_key_exists($id, $countPerDocument)) {
$countPerDocument[$id] = 0;
}
}
if (empty($for['folders'])) {
$folderIds = Document::with('folders')->find($for['documents'])->pluck('folders')->flatten()->pluck('id');
$for['folders'] = Folder::find($folderIds);
}
}
foreach ($for['folders'] as $folder) {
$countPerFolder[$folder->id] = $this->feedItemStates()->unread()->whereIn('document_id', $folder->getDocumentIds())->count();
}
foreach ($this->groups as $group) {
$totalUnreadItems = $group->getUnreadFeedItemsCountFor($this);
$unreadItemsFolderId = $group->folders()->ofType('unread_items')->first()->id;
$countPerFolder[$unreadItemsFolderId] = $totalUnreadItems;
$countPerGroup[$group->id] = $totalUnreadItems;
}
return [
'documents' => $countPerDocument,
'folders' => $countPerFolder,
'groups' => $countPerGroup,
'updated_feed_items' => !empty($for['updated_feed_items']) ? $for['updated_feed_items'] : null,
];
}
}

View File

@@ -0,0 +1,244 @@
<?php
namespace App\Models\Traits\User;
use App\Models\Folder;
use App\Models\Group;
use App\Models\Permission;
trait HasFolders
{
// -------------------------------------------------------------------------
// ----| Properties |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Currently selected folder in each group.
*
* @var array
*/
protected $selectedFolders = [];
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Folders owned (created) by this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function createdFolders()
{
return $this->hasMany(Folder::class);
}
// -------------------------------------------------------------------------
// ----| Methods |----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Return user's folders as a flat tree.
*
* @return \Illuminate\Support\Collection
*/
public function getFlatTree(Group $group = null)
{
if (empty($group)) {
$group = $this->selectedGroup();
}
return Folder::getFlatTreeFor($this, $group);
}
/**
* Return current user's selected folder in specified group.
*
* @return null|\App\Models\Group
*/
public function selectedFolder(Group $group = null)
{
if (empty($group)) {
$group = $this->selectedGroup();
}
if (empty($this->selectedFolders[$group->id])) {
$this->selectedFolders[$group->id] = $this->fetchSelectedFolder($group);
}
return $this->selectedFolders[$group->id];
}
/**
* Remember user's selected folder in specified (or current) group.
*
* @param \App\Models\Folder $folder
*
* @return array
*/
public function setSelectedFolder(Folder $folder = null, Group $group = null)
{
if (empty($group)) {
$group = $this->selectedGroup();
}
$this->selectedFolders[$group->id] = $folder;
$this->storeSelectedFolder($group);
}
/**
* Return specified folder's expanded/collapsed state in specified group.
*
* @return bool
*/
public function getFolderExpandedState(Folder $folder = null, Group $group = null)
{
if (empty($folder)) {
$folder = $this->selectedFolder($group);
}
$key = $this->folderExpandedStoreKey($folder, $group);
return (bool) cache($key, false);
}
/**
* Set specified folder's expanded/collapsed state in specified group.
*
* @param bool $expanded
* @param bool $recursive Apply new state recursively
*/
public function setFolderExpandedState($expanded, Folder $folder = null, Group $group = null, $recursive = false)
{
$key = $this->folderExpandedStoreKey($folder, $group);
cache()->forever($key, (bool) $expanded);
if ($recursive) {
foreach ($folder->children as $subFolder) {
$this->setFolderExpandedState($expanded, $subFolder, $group, $recursive);
}
}
}
/**
* Make sure specified folder's ancestor are all expanded, so the folder is
* visible.
*
* @param \App\Models\Folder $folder
*/
public function ensureAncestorsAreExpanded(Folder $folder = null)
{
if (empty($folder)) {
$folder = $this->selectedFolder();
}
while ($folder = $folder->parent) {
$this->setFolderExpandedState(true, $folder);
}
}
/**
* Define this user permission for specified folder and ability.
*
* @param null|mixed $ability
* @param mixed $grant
*/
public function setFolderPermissions(Folder $folder, $ability = null, $grant = false)
{
$permissions = $this->permissions()->where('folder_id', $folder->id)->first();
if (!$permissions) {
$permissions = new Permission();
$permissions->user()->associate($this);
$permissions->folder()->associate($folder);
}
$defaultPermissions = $folder->getDefaultPermissions();
if ($ability !== null) {
$permissions->{$ability} = $grant;
} else {
foreach ($defaultPermissions as $defaultAbility => $defaultGrant) {
$permissions->{$defaultAbility} = $defaultGrant;
}
}
$permissions->save();
}
/**
* Return the key to access reminded selected folder for this user and
* specified group.
*
* @return string
*/
protected function selectedFolderStoreKey(Group $group)
{
if (empty($group)) {
$group = $this->selectedGroup();
}
return sprintf('selectedFolder.%d.%d', $this->id, $group->id);
}
/**
* Return stored user's selected folder in specified group.
*
* @return \App\Models\Folder
*/
protected function fetchSelectedFolder(Group $group)
{
$key = $this->selectedFolderStoreKey($group);
if (cache()->has($key)) {
$folder = $group->folders()->find(cache($key));
if (!empty($folder)) {
return $folder;
}
}
return $group->folders()->ofType('root')->first();
}
/**
* Save user's selected folder in specified group.
*/
protected function storeSelectedFolder(Group $group)
{
if (empty($group)) {
$group = $this->selectedGroup();
}
$key = $this->selectedFolderStoreKey($group);
$folder = $this->selectedFolders[$group->id];
if (!empty($folder)) {
cache()->forever($key, $folder->id);
} else {
cache()->forget($key);
}
}
/**
* Return the key to get specified folder's expanded/collased state in
* specified group.
*
* @return string
*/
protected function folderExpandedStoreKey(Folder $folder = null, Group $group = null)
{
if (empty($group)) {
$group = $this->selectedGroup();
}
if (empty($folder)) {
$folder = $this->selectedFolder($group);
}
return sprintf('folderExpandedState.%d.%d.%d', $this->id, $group->id, $folder->id);
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace App\Models\Traits\User;
use App\Models\Group;
trait HasGroups
{
// -------------------------------------------------------------------------
// ----| Properties |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Currently selected group.
*
* @var \App\Models\Group
*/
protected $selectedGroup;
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Groups created by this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function createdGroups()
{
return $this->hasMany(Group::class);
}
/**
* Associated groups.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function groups()
{
return $this->belongsToMany(Group::class, 'user_groups')->withPivot(['status', 'position']);
}
// -------------------------------------------------------------------------
// ----| Methods |----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Create and return user's primary group.
*
* @return \App\Models\Group
*/
public function createOwnGroup()
{
$group = Group::create([
'name' => $this->name,
'invite_only' => true,
'user_id' => $this->id,
]);
$this->groups()->attach($group, [
'status' => Group::$STATUS_OWN,
]);
return $group;
}
/**
* Return current user's selected group.
*
* @return \App\Models\Group
*/
public function selectedGroup()
{
if ($this->selectedGroup === null) {
$this->selectedGroup = $this->fetchSelectedGroup();
}
return $this->selectedGroup;
}
/**
* Remember user's selected group.
*/
public function setSelectedGroup(Group $group)
{
$this->selectedGroup = $group;
$this->storeSelectedGroup();
return $this->getFlatTree();
}
/**
* Return a list of groups user is active in.
*
* @return \Illuminate\Support\Collection
*/
public function listActiveGroups()
{
$userId = $this->id;
return $this->groups()
->select([
'groups.id',
'groups.name',
])->active()
->orderBy('position')->get();
}
/**
* Update group status for current user. If group is not associated with
* user, association will be made. It won't change user group if it is
* marked as being owned or created by current user, unless $force is true.
*
* @param string $newStatus
* @param mixed $force
*/
public function updateGroupStatus(Group $group, $newStatus, $force = false)
{
$userGroup = $this->groups()->find($group->id);
if ($userGroup) {
if (in_array($userGroup->pivot->status, [Group::$STATUS_OWN, Group::$STATUS_CREATED]) && !$force) {
return;
}
$this->groups()->updateExistingPivot($group->id, [
'status' => $newStatus,
]);
} else {
$this->groups()->save($group, [
'status' => $newStatus,
]);
}
}
/**
* Return the key to access reminded selected group for this user.
*
* @return string
*/
protected function selectedGroupStoreKey()
{
return sprintf('selectedGroup.%d', $this->id);
}
/**
* Return stored user's selected group.
*
* @return \App\Models\Group
*/
protected function fetchSelectedGroup()
{
$key = $this->selectedGroupStoreKey();
if (cache()->has($key)) {
$group = $this->groups()->active()->find(cache($key));
if (!empty($group)) {
return $group;
}
}
return $this->groups()->own()->first();
}
/**
* Save user's selected group.
*/
protected function storeSelectedGroup()
{
$key = $this->selectedGroupStoreKey();
cache()->forever($key, $this->selectedGroup->id);
}
}

125
app/Models/User.php Executable file
View File

@@ -0,0 +1,125 @@
<?php
namespace App\Models;
use App\Models\Traits\User\HasFeeds;
use App\Models\Traits\User\HasFolders;
use App\Models\Traits\User\HasGroups;
use App\Services\Importer;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable implements MustVerifyEmail, HasLocalePreference
{
use Notifiable;
use HasGroups;
use HasFolders;
use HasFeeds;
// -------------------------------------------------------------------------
// ----| Properties |-------------------------------------------------------
// -------------------------------------------------------------------------
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name', 'email', 'password', 'lang'
];
/**
* The attributes that should be hidden for arrays.
*
* @var array
*/
protected $hidden = [
'password', 'remember_token',
];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'email_verified_at' => 'datetime',
];
public function getLangAttribute() {
if(!empty($this->attributes['lang'])) {
return trim($this->attributes['lang']);
}
return 'en';
}
// -------------------------------------------------------------------------
// ----| Relations |--------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Documents added to user's collection.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function documents()
{
return $this->hasManyThrough(Bookmark::class, Folder::class);
}
/**
* Highlights registered by this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function highlights()
{
return $this->hasMany(Highlight::class);
}
/**
* Associated history entries.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function userHistoryEntries()
{
return $this->hasMany(HistoryEntry::class);
}
/**
* Permissions affected to this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function permissions()
{
return $this->hasMany(Permission::class);
}
// -------------------------------------------------------------------------
// ----| Methods |----------------------------------------------------------
// -------------------------------------------------------------------------
/**
* Get the user's preferred locale.
*
* @return string
*/
public function preferredLocale()
{
return $this->lang;
}
/**
* Import initial set of data.
*/
public function importInitialData(Group $group)
{
$importer = new Importer();
$importer->forUser($this)->inGroup($group)->fromFile(resource_path('initial_data.json'))->import();
}
}