jonnybarnes.uk/app/Models/Note.php
Jonny Barnes e4fe2ecde3 Struct Types
Squashed commit of the following:

commit 74ed84617fcbecf661695763323e50d049a88db7
Author: Jonny Barnes <jonny@jonnybarnes.uk>
Date:   Mon Jan 15 12:46:29 2018 +0000

    Test passes so remove the dump statement

commit a7d3323be02da64f76e8ec88713e3de84a13ded7
Author: Jonny Barnes <jonny@jonnybarnes.uk>
Date:   Mon Jan 15 12:40:35 2018 +0000

    Values with spaces need to be quoted

commit 58a120bb238f14346793c388b948b7351d3b51fd
Author: Jonny Barnes <jonny@jonnybarnes.uk>
Date:   Mon Jan 15 12:37:23 2018 +0000

    We need a diplay name for the tests to work now we are using strict type checking

commit b46f177053bd697db9a4835d073f2f37e088b26f
Author: Jonny Barnes <jonny@jonnybarnes.uk>
Date:   Mon Jan 15 12:31:29 2018 +0000

    Get travis to show more info about failing test

commit 60323f3ce5a0561329a1721ee94821571cdcc86a
Author: Jonny Barnes <jonny@jonnybarnes.uk>
Date:   Mon Jan 15 12:23:27 2018 +0000

    Remove un-used namnepsace imports

commit 096d3505920bc94ff8677c77430eca0aae0be58a
Author: Jonny Barnes <jonny@jonnybarnes.uk>
Date:   Mon Jan 15 12:21:55 2018 +0000

    we need php7.2 for object type-hint

commit bb818bc19c73d02d510af9f002199f5718a54608
Author: Jonny Barnes <jonny@jonnybarnes.uk>
Date:   Mon Jan 15 12:15:48 2018 +0000

    Added lots of strict_types
2018-01-15 14:02:13 +00:00

586 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace App\Models;
use Cache;
use Twitter;
use Normalizer;
use GuzzleHttp\Client;
use Laravel\Scout\Searchable;
use League\CommonMark\Converter;
use League\CommonMark\DocParser;
use Jonnybarnes\IndieWeb\Numbers;
use League\CommonMark\Environment;
use League\CommonMark\HtmlRenderer;
use Illuminate\Database\Eloquent\Model;
use Jonnybarnes\EmojiA11y\EmojiModifier;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletes;
use Jonnybarnes\CommonmarkLinkify\LinkifyExtension;
class Note extends Model
{
use Searchable;
use SoftDeletes;
/**
* The reges for matching lone usernames.
*
* @var string
*/
private const USERNAMES_REGEX = '/\[.*?\](*SKIP)(*F)|@(\w+)/';
/**
* This variable is used to keep track of contacts in a note.
*/
protected $contacts;
/**
* Set our contacts variable to null.
*
* @param array $attributes
*/
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->contacts = null;
}
/**
* The database table used by the model.
*
* @var string
*/
protected $table = 'notes';
/*
* Mass-assignment
*
* @var array
*/
protected $fillable = [
'note',
'in_reply_to',
'client_id',
];
/**
* Hide the column used with Laravel Scout.
*
* @var array
*/
protected $hidden = ['searchable'];
/**
* Define the relationship with tags.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function tags()
{
return $this->belongsToMany('App\Models\Tag');
}
/**
* Define the relationship with clients.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function client()
{
return $this->belongsTo('App\Models\MicropubClient', 'client_id', 'client_url');
}
/**
* Define the relationship with webmentions.
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function webmentions()
{
return $this->morphMany('App\Models\WebMention', 'commentable');
}
/**
* Define the relationship with places.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function place()
{
return $this->belongsTo('App\Models\Place');
}
/**
* Define the relationship with media.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function media()
{
return $this->hasMany('App\Models\Media');
}
/**
* Set the attributes to be indexed for searching with Scout.
*
* @return array
*/
public function toSearchableArray(): array
{
return [
'note' => $this->note,
];
}
/**
* Normalize the note to Unicode FORM C.
*
* @param string|null $value
*/
public function setNoteAttribute(?string $value)
{
if ($value !== null) {
$normalized = normalizer_normalize($value, Normalizer::FORM_C);
if ($normalized === '') { //we dont want to save empty strings to the db
$normalized = null;
}
$this->attributes['note'] = $normalized;
}
}
/**
* Pre-process notes for web-view.
*
* @param string|null $value
* @return string|null
*/
public function getNoteAttribute(?string $value): ?string
{
if ($value === null && $this->place !== null) {
$value = '📍: <a href="' . $this->place->longurl . '">' . $this->place->name . '</a>';
}
// if $value is still null, just return null
if ($value === null) {
return null;
}
$hcards = $this->makeHCards($value);
$hashtags = $this->autoLinkHashtag($hcards);
$html = $this->convertMarkdown($hashtags);
$modified = resolve(EmojiModifier::class)->makeEmojiAccessible($html);
return $modified;
}
/**
* Generate the NewBase60 ID from primary ID.
*
* @return string
*/
public function getNb60idAttribute(): string
{
// we cast to string because sometimes the nb60id is an “int”
return (string) resolve(Numbers::class)->numto60($this->id);
}
/**
* The Long URL for a note.
*
* @return string
*/
public function getLongurlAttribute(): string
{
return config('app.url') . '/notes/' . $this->nb60id;
}
/**
* The Short URL for a note.
*
* @return string
*/
public function getShorturlAttribute(): string
{
return config('app.shorturl') . '/notes/' . $this->nb60id;
}
/**
* Get the ISO8601 value for mf2.
*
* @return string
*/
public function getIso8601Attribute(): string
{
return $this->updated_at->toISO8601String();
}
/**
* Get the ISO8601 value for mf2.
*
* @return string
*/
public function getHumandiffAttribute(): string
{
return $this->updated_at->diffForHumans();
}
/**
* Get the pubdate value for RSS feeds.
*
* @return string
*/
public function getPubdateAttribute(): string
{
return $this->updated_at->toRSSString();
}
/**
* Get the latitude value.
*
* @return float|null
*/
public function getLatitudeAttribute(): ?float
{
if ($this->place !== null) {
return $this->place->location->getLat();
}
if ($this->location !== null) {
$pieces = explode(':', $this->location);
$latlng = explode(',', $pieces[0]);
return (float) trim($latlng[0]);
}
return null;
}
/**
* Get the longitude value.
*
* @return float|null
*/
public function getLongitudeAttribute(): ?float
{
if ($this->place !== null) {
return $this->place->location->getLng();
}
if ($this->location !== null) {
$pieces = explode(':', $this->location);
$latlng = explode(',', $pieces[0]);
return (float) trim($latlng[1]);
}
return null;
}
/**
* Get the address for a note. This is either a reverse geo-code from the
* location, or is derived from the associated place.
*
* @return string|null
*/
public function getAddressAttribute(): ?string
{
if ($this->place !== null) {
return $this->place->name;
}
if ($this->location !== null) {
return $this->reverseGeoCode((float) $this->latitude, (float) $this->longitude);
}
return null;
}
/**
* Get the OEmbed html for a tweet the note is a reply to.
*
* @return object|null
*/
public function getTwitterAttribute(): ?object
{
if ($this->in_reply_to == null || mb_substr($this->in_reply_to, 0, 20, 'UTF-8') !== 'https://twitter.com/') {
return null;
}
$tweetId = basename($this->in_reply_to);
if (Cache::has($tweetId)) {
return Cache::get($tweetId);
}
try {
$oEmbed = Twitter::getOembed([
'url' => $this->in_reply_to,
'dnt' => true,
'align' => 'center',
'maxwidth' => 512,
]);
} catch (\Exception $e) {
return null;
}
Cache::put($tweetId, $oEmbed, ($oEmbed->cache_age / 60));
return $oEmbed;
}
/**
* Show a specific form of the note for twitter.
*
* That is we swap the contacts names for their known Twitter handles.
*
* @return string|null
*/
public function getTwitterContentAttribute(): ?string
{
if ($this->contacts === null) {
return null;
}
if (count($this->contacts) === 0) {
return null;
}
if (count(array_unique(array_values($this->contacts))) === 1
&& array_unique(array_values($this->contacts))[0] === null) {
return null;
}
// swap in twitter usernames
$swapped = preg_replace_callback(
self::USERNAMES_REGEX,
function ($matches) {
if (is_null($this->contacts[$matches[1]])) {
return $matches[0];
}
$contact = $this->contacts[$matches[1]];
if ($contact->twitter) {
return '@' . $contact->twitter;
}
return $contact->name;
},
$this->getOriginal('note')
);
return $this->convertMarkdown($swapped);
}
/**
* Show a specific form of the note for facebook.
*
* That is we swap the contacts names for their known Facebook usernames.
*
* @return string|null
*/
public function getFacebookContentAttribute(): ?string
{
if (count($this->contacts) === 0) {
return null;
}
if (count(array_unique(array_values($this->contacts))) === 1
&& array_unique(array_values($this->contacts))[0] === null) {
return null;
}
// swap in facebook usernames
$swapped = preg_replace_callback(
self::USERNAMES_REGEX,
function ($matches) {
if (is_null($this->contacts[$matches[1]])) {
return $matches[0];
}
$contact = $this->contacts[$matches[1]];
if ($contact->facebook) {
return '<a class="u-category h-card" href="https://facebook.com/'
. $contact->facebook . '">' . $contact->name . '</a>';
}
return $contact->name;
},
$this->getOriginal('note')
);
return $this->convertMarkdown($swapped);
}
/**
* Scope a query to select a note via a NewBase60 id.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $nb60id
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeNb60(Builder $query, string $nb60id): Builder
{
return $query->where('id', resolve(Numbers::class)->b60tonum($nb60id));
}
/**
* Swap contacts nicks for a full mf2 h-card.
*
* Take note that this method does two things, given @username (NOT [@username](URL)!)
* we try to create a fancy hcard from our contact info. If this is not possible
* due to lack of contact info, we assume @username is a twitter handle and link it
* as such.
*
* @param string $text
* @return string
*/
private function makeHCards(string $text): string
{
$this->getContacts();
if (count($this->contacts) === 0) {
return $text;
}
$hcards = preg_replace_callback(
self::USERNAMES_REGEX,
function ($matches) {
if (is_null($this->contacts[$matches[1]])) {
return '<a href="https://twitter.com/' . $matches[1] . '">' . $matches[0] . '</a>';
}
$contact = $this->contacts[$matches[1]]; // easier to read the following code
$host = parse_url($contact->homepage, PHP_URL_HOST);
$contact->photo = '/assets/profile-images/default-image';
if (file_exists(public_path() . '/assets/profile-images/' . $host . '/image')) {
$contact->photo = '/assets/profile-images/' . $host . '/image';
}
return trim(view('templates.mini-hcard', ['contact' => $contact])->render());
},
$text
);
return $hcards;
}
/**
* Get the value of the `contacts` property.
*/
public function getContacts()
{
if ($this->contacts === null) {
$this->setContacts();
}
}
/**
* Process the note and save the contacts to the `contacts` property.
*/
public function setContacts()
{
$contacts = [];
if ($this->getOriginal('note')) {
preg_match_all(self::USERNAMES_REGEX, $this->getoriginal('note'), $matches);
foreach ($matches[1] as $match) {
$contacts[$match] = Contact::where('nick', mb_strtolower($match))->first();
}
}
$this->contacts = $contacts;
}
/**
* Turn text hashtags to full HTML links.
*
* Given a string and section, finds all hashtags matching
* `#[\-_a-zA-Z0-9]+` and wraps them in an `a` element with
* `rel=tag` set and a `href` of 'section/tagged/' + tagname without the #.
*
* @param string $note
* @return string
*/
public function autoLinkHashtag(string $note): string
{
return preg_replace_callback(
'/#([^\s]*)\b/',
function ($matches) {
return '<a rel="tag" class="p-category" href="/notes/tagged/'
. Tag::normalize($matches[1]) . '">#'
. $matches[1] . '</a>';
},
$note
);
}
/**
* Pass a note through the commonmark library.
*
* @param string $note
* @return string
*/
private function convertMarkdown(string $note): string
{
$environment = Environment::createCommonMarkEnvironment();
$environment->addExtension(new LinkifyExtension());
$converter = new Converter(new DocParser($environment), new HtmlRenderer($environment));
return $converter->convertToHtml($note);
}
/**
* Do a reverse geocode lookup of a `lat,lng` value.
*
* @param float $latitude
* @param float $longitude
* @return string
*/
public function reverseGeoCode(float $latitude, float $longitude): string
{
$latlng = $latitude . ',' . $longitude;
return Cache::get($latlng, function () use ($latlng, $latitude, $longitude) {
$guzzle = new Client();
$response = $guzzle->request('GET', 'https://nominatim.openstreetmap.org/reverse', [
'query' => [
'format' => 'json',
'lat' => $latitude,
'lon' => $longitude,
'zoom' => 18,
'addressdetails' => 1,
],
'headers' => ['User-Agent' => 'jonnybarnes.uk via Guzzle, email jonny@jonnybarnes.uk'],
]);
$json = json_decode((string) $response->getBody());
if (isset($json->address->town)) {
$address = '<span class="p-locality">'
. $json->address->town
. '</span>, <span class="p-country-name">'
. $json->address->country
. '</span>';
Cache::forever($latlng, $address);
return $address;
}
if (isset($json->address->city)) {
$address = $json->address->city . ', ' . $json->address->country;
Cache::forever($latlng, $address);
return $address;
}
if (isset($json->address->county)) {
$address = '<span class="p-region">'
. $json->address->county
. '</span>, <span class="p-country-name">'
. $json->address->country
. '</span>';
Cache::forever($latlng, $address);
return $address;
}
$address = '<span class="p-country-name">' . $json->address->country . '</span>';
Cache::forever($latlng, $address);
return $address;
});
}
}