Refresh
This commit is contained in:
49
app/Models/Bookmark.php
Executable file
49
app/Models/Bookmark.php
Executable 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
288
app/Models/Document.php
Executable 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
248
app/Models/Feed.php
Executable 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
157
app/Models/FeedItem.php
Executable 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
62
app/Models/FeedItemState.php
Executable 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
403
app/Models/Folder.php
Executable 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
265
app/Models/Group.php
Executable 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
16
app/Models/Highlight.php
Executable 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
34
app/Models/IgnoredFeed.php
Executable 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);
|
||||
}
|
||||
}
|
||||
25
app/Models/Observers/BookmarkObserver.php
Executable file
25
app/Models/Observers/BookmarkObserver.php
Executable 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
56
app/Models/Observers/DocumentObserver.php
Executable file
56
app/Models/Observers/DocumentObserver.php
Executable 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
17
app/Models/Observers/FeedItemObserver.php
Executable file
17
app/Models/Observers/FeedItemObserver.php
Executable 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());
|
||||
}
|
||||
}
|
||||
45
app/Models/Observers/FeedObserver.php
Executable file
45
app/Models/Observers/FeedObserver.php
Executable 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
32
app/Models/Observers/FolderObserver.php
Executable file
32
app/Models/Observers/FolderObserver.php
Executable 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]]));
|
||||
}
|
||||
}
|
||||
16
app/Models/Observers/GroupObserver.php
Executable file
16
app/Models/Observers/GroupObserver.php
Executable 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();
|
||||
}
|
||||
}
|
||||
22
app/Models/Observers/IgnoredFeedObserver.php
Executable file
22
app/Models/Observers/IgnoredFeedObserver.php
Executable 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
18
app/Models/Observers/UserObserver.php
Executable file
18
app/Models/Observers/UserObserver.php
Executable 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
65
app/Models/Permission.php
Executable 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);
|
||||
}
|
||||
}
|
||||
78
app/Models/Policies/DocumentPolicy.php
Executable file
78
app/Models/Policies/DocumentPolicy.php
Executable 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
211
app/Models/Policies/FolderPolicy.php
Executable file
211
app/Models/Policies/FolderPolicy.php
Executable 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);
|
||||
}
|
||||
}
|
||||
135
app/Models/Policies/GroupPolicy.php
Executable file
135
app/Models/Policies/GroupPolicy.php
Executable 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;
|
||||
}
|
||||
}
|
||||
226
app/Models/Traits/Document/AnalysesDocument.php
Executable file
226
app/Models/Traits/Document/AnalysesDocument.php
Executable 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();
|
||||
}
|
||||
}
|
||||
222
app/Models/Traits/Feed/AnalysesFeed.php
Executable file
222
app/Models/Traits/Feed/AnalysesFeed.php
Executable 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]));
|
||||
}
|
||||
}
|
||||
79
app/Models/Traits/Folder/BuildsTree.php
Executable file
79
app/Models/Traits/Folder/BuildsTree.php
Executable 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;
|
||||
}
|
||||
}
|
||||
57
app/Models/Traits/Folder/CreatesDefaultFolders.php
Executable file
57
app/Models/Traits/Folder/CreatesDefaultFolders.php
Executable 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
50
app/Models/Traits/HasUrl.php
Executable 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;
|
||||
}
|
||||
}
|
||||
171
app/Models/Traits/User/HasFeeds.php
Executable file
171
app/Models/Traits/User/HasFeeds.php
Executable 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
244
app/Models/Traits/User/HasFolders.php
Executable file
244
app/Models/Traits/User/HasFolders.php
Executable 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);
|
||||
}
|
||||
}
|
||||
177
app/Models/Traits/User/HasGroups.php
Executable file
177
app/Models/Traits/User/HasGroups.php
Executable 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
125
app/Models/User.php
Executable 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user