diff --git a/plugins/Pinboard/Controller/API.php b/plugins/Pinboard/Controller/API.php deleted file mode 100644 index 9b6a00a0f1..0000000000 --- a/plugins/Pinboard/Controller/API.php +++ /dev/null @@ -1,133 +0,0 @@ -string('format'); - $auth_token = $this->string('auth_token'); - if ($format !== 'json') { - throw new ServerException(_m('Only JSON is supported')); - } - - if (\is_null($auth_token)) { - return new Response('API requires authentication', status: 401); - } - - if (\is_null($user = Util::validateToken($auth_token))) { - return new Response('401 Forbidden', status: 401); - } - - return $user; - } - - /** - * Last post update - */ - public function posts_update(Request $request) - { - self::before(); - - return new JsonResponse( - ['update_time' => date(\DATE_ISO8601)], // TODO fetch latest and reply accordingly - status: 200, - headers: ['content-type' => 'application/json'], - ); - } - - /** - * Add a pin - */ - public function posts_add(Request $request) - { - $user = self::before(); - - if (\is_null($url = $this->string('url'))) { - throw new ClientException('URL must be provided'); - } - - if (\is_null($title = $this->string('description'))) { // Logically. - throw new ClientException('Desciption must be provided'); - } - - $description = $this->string('extended') ?? ''; - - $tags = []; - if (!\is_null($tags_text = $this->string('tags'))) { - Formatting::toArray($tags_text, $tags, Formatting::SPLIT_BY_BOTH); - } - - $modified = $this->string('dt') ?? new Datetime; - $replace = $this->bool('replace') ?? true; - $public = $this->bool('shared') ?? true; - $unread = $this->bool('toread') ?? false; - - $result_code = 'something went wrong'; - [$pin, $existed] = Pin::checkExistingAndCreateOrUpdate( - args: [ - 'actor_id' => $user->getId(), - 'url_hash' => hash('sha256', $url), - 'url' => $url, - 'replace' => $replace, - 'public' => $public, - 'unread' => $unread, - 'modified' => $modified, - ], - find_by_keys: ['actor_id', 'url_hash'], - ); - - if ($existed) { - if (!$replace) { - $result_code = 'item already exists'; - } else { - throw new ServerException('Updating is unimplemented'); // TODO delete old note, create new one - } - } else { - DB::persist($note = Note::create([ - 'actor_id' => $user->getId(), - 'content' => $url, - 'content_type' => 'text/uri-list', - 'rendered' => Formatting::twigRenderFile('pinboard/render.html.twig', ['url' => $url, 'title' => $title, 'description' => $description]), - 'reply_to' => null, - 'is_local' => true, - 'source' => 'Pinboard API', - 'scope' => $public ? VisibilityScope::EVERYWHERE->value : VisibilityScope::ADDRESSEE->value, - 'language_id' => $user->getActor()->getTopLanguage()->getId(), - 'type' => 'pin', - 'title' => $title, - ])); - $note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL)); - $pin->setNoteId($note->getId()); - Conversation::assignLocalConversation($note, null); - // TODO handle tags - DB::flush(); - $result_code = 'done'; - } - - return new JsonResponse( - ['result_code' => $result_code], - status: 200, - headers: ['content-type' => 'application/json'], - ); - } -} diff --git a/plugins/Pinboard/Controller/APIv1.php b/plugins/Pinboard/Controller/APIv1.php new file mode 100644 index 0000000000..8c4afa6b1f --- /dev/null +++ b/plugins/Pinboard/Controller/APIv1.php @@ -0,0 +1,327 @@ +string('format'); + $auth_token = $this->string('auth_token'); + if (\is_null($format)) { + return new Response(_m('Format must be specified'), status: 400); + } + + if ($format !== 'json') { + throw new ServerException(_m('Only JSON is supported')); + } + + if (\is_null($auth_token)) { + return new Response('API requires authentication', status: 401); + } + + if (\is_null($user = Util::validateToken($auth_token))) { + return new Response('401 Forbidden', status: 401); + } + + return $user; + } + + private function respond(array $result, int $status = 200): Response + { + $format = $this->string('format'); + if ($format === 'json') { + return new JsonResponse($result, status: $status); + } elseif ($format === 'xml') { + // $encoder = new XmlEncoder(); + // $xml = $encoder->encode($result, 'xml'); + [$tag_names, $keys] = F\zip(...F\map(array_keys($result), fn (string $k) => explode('_', $k))); + $xml = new SimpleXMLElement('<' . $tag_names[0] . '/>'); + dd($tag_names, $keys, $result, $xml, (string) $xml); + $xml->addChild(); + dd($xml); + return new Response(content: $xml, status: $statis); + } + } + + private function deleteNoteAndMaybePin(LocalUser $user, Note $note, ?Pin $pin): void + { + $note->delete($user->getActor(), self::SOURCE); + if (!\is_null($note)) { + DB::remove($pin); + } + DB::flush(); + } + + private function parseTags(): array + { + $tags = []; + if (!\is_null($tags_text = $this->string('tags'))) { + Formatting::toArray($tags_text, $tags, Formatting::SPLIT_BY_BOTH); + } + return $tags; + } + + private function getLatestModified(LocalUser $user): string + { + return Cache::get( + Pin::cacheKeys($user)['last-modified'], + fn () => DB::dql( + <<<'EOF' + select MAX(p.modified) as max from \Plugin\Pinboard\Entity\Pin p + where p.actor_id = :actor_id + group by p.modified + EOF, + ['actor_id' => $user->getId()], + )[0]['max'], + ); + } + + /** + * @param Pin[] $pins + * + * @return array json for response + */ + private function formatPins(LocalUser $user, array $pins): array + { + return [ + 'date' => self::getLatestModified($user), + 'user' => $user->getNickname(), + 'posts' => F\map( + $pins, + fn (Pin $pin) => [ + 'href' => $pin->getUrl(), + 'description' => $pin->getTitle(), + 'extended' => $pin->getDescription(), + 'meta' => hash('md5', $pin->getModified()->format(DateTimeInterface::ISO8601)), + 'hash' => hash('md5', $pin->getUrl() . $pin->getTitle() . $pin->getDescription()), // idk... + 'time' => $pin->getModified()->format(DateTimeInterface::ISO8601), + 'shared' => $pin->getPublic(), + 'toread' => $pin->getUnread(), + 'tags' => implode(' ', F\map($pin->getTags(), fn (NoteTag $tag) => $tag->getTag())), + ], + ), + ]; + } + + // ---------------------- + + /** + * Returns the most recent time a bookmark was added, updated or + * deleted. Use this before calling posts/all to see if the data + * has changed since the last fetch + */ + public function posts_update(Request $request) + { + $user = self::before(); + + return self::respond(['update_time' => self::getLatestModified($user)]); + } + + /** + * Add a bookmark + */ + public function posts_add(Request $request) + { + $user = self::before(); + + if (\is_null($url = $this->string('url'))) { + throw new ClientException('URL must be provided'); + } + + if (\is_null($title = $this->string('description'))) { // Logically. + throw new ClientException('Desciption must be provided'); + } + + $description = $this->string('extended') ?? ''; + $tags = self::parseTags(); + $modified = $this->string('dt') ?? new Datetime; + $replace = $this->bool('replace') ?? true; + $public = $this->bool('shared') ?? true; + $unread = $this->bool('toread') ?? false; + + $result_code = 'something went wrong'; + [$pin, $existed] = Pin::checkExistingAndCreateOrUpdate( + args: [ + 'actor_id' => $user->getId(), + 'url_hash' => hash('sha256', $url), + 'url' => $url, + 'title' => $title, + 'description' => $description, + 'replace' => $replace, + 'public' => $public, + 'unread' => $unread, + 'modified' => $modified, + ], + find_by_keys: ['actor_id', 'url_hash'], + ); + + if ($existed) { + if (!$replace) { + $result_code = 'item already exists'; + } else { + throw new ServerException('Updating is unimplemented'); // TODO delete old note, create new one + $this->deleteNoteAndMaybePin($user, $pin->getNote(), pin: null); + } + } else { + DB::persist($note = Note::create([ + 'actor_id' => $user->getId(), + 'content' => $url, + 'content_type' => 'text/uri-list', + 'rendered' => Formatting::twigRenderFile('pinboard/render.html.twig', ['url' => $url, 'title' => $title, 'description' => $description]), + 'reply_to' => null, + 'is_local' => true, + 'source' => self::SOURCE, + 'scope' => $public ? VisibilityScope::EVERYWHERE->value : VisibilityScope::ADDRESSEE->value, + 'language_id' => $user->getActor()->getTopLanguage()->getId(), + 'type' => 'page', + 'title' => $title, + ])); + $note->setUrl(Router::url('note_view', ['id' => $note->getId()], Router::ABSOLUTE_URL)); + $pin->setNoteId($note->getId()); + Conversation::assignLocalConversation($note, null); + DB::persist($pin); + // TODO handle tags + DB::flush(); + $result_code = 'done'; + } + + return self::respond(['result_code' => $result_code]); + } + + /** + * Delete a bookmark + */ + public function posts_delete(Request $request) + { + $user = self::before($request); + $url = $this->string('url'); + if (\is_null($url)) { + throw new ClientException('URL must be provided'); + } + + $pin = DB::findOneBy(Pin::class, ['actor_id' => $user->getId(), 'url_hash' => hash('sha256', $url)], return_null: true); + if (\is_null($pin)) { + return self::respond(['result_code' => 'item not found']); + } else { + $this->deleteNoteAndMaybePin($user, $pin->getNote(), $pin); + return self::respond(['result_code' => 'done']); + } + } + + /** + * Returns one or more posts on a single day matching the + * arguments. If no date or url is given, date of most recent + * bookmark will be used + */ + public function posts_get(Request $request) + { + $user = self::before($request); + $tags = self::parseTags(); + $day = $this->string('dt'); + $url = $this->string('url'); + $meta = $this->bool('meta'); + + if (!\is_null($tags) && $tags !== []) { + throw new ClientException(_m('tags attribute not implemented')); + } + + if (\is_null($url) && \is_null($day)) { + $pins = DB::findBy(Pin::class, ['actor_id' => $user->getId(), 'gte' => ['modified' => new DateTimeImmutable('today')]]); + } elseif (!\is_null($day)) { + $day = new DateTimeImmutable($day); + $pins = DB::findBy(Pin::class, ['actor_id' => $user->getId(), 'gte' => ['modified' => $day], 'lt' => ['modified' => $day->modify('+1 day')]]); + } elseif (!\is_null($url)) { + $pins = DB::findBy(Pin::class, ['actor_id' => $user->getId(), 'url_hash' => hash('sha256', $url)]); + } else { + throw new BugFoundException('Wonky logic in pinboard/posts/get'); + } + + return self::respond(self::formatPins($user, $pins)); + } + + /** + * Returns a list of the user's most recent posts, filtered by tag + */ + public function posts_recent(Request $request) + { + $user = self::before($request); + $tags = self::parseTags(); + $limit = min($this->int('count') ?? 15, 100); + + if (!\is_null($tags) && $tags !== []) { + throw new ClientException('tags attribute not implemented'); + } + + $pins = DB::findBy(Pin::class, ['actor_id' => $user->getId()], order_by: ['modified' => 'asc'], limit: $limit); + + return self::respond(self::formatPins($user, $pins)); + } + + /** + * Returns a list of dates with the number of posts at each date + */ + public function posts_dates(Request $request) + { + return self::respond(['result_code' => 'unimplemented']); + } + + /** + * Returns all bookmarks in the user's account + */ + public function posts_all(Request $request) + { + $user = self::before($request); + $tags = self::parseTags(); + $offset = $this->int('start'); + $limit = $this->int('results'); + $start_time = $this->string('fromdt'); + $end_time = $this->string('todt'); + $meta = $this->bool('meta'); + + if (!\is_null($tags) && $tags !== []) { + throw new ClientException('tags attribute not implemented'); + } + + $criteria = ['actor_id' => $user->getId()]; + if (!\is_null($start_time)) { + $criteria['gte'] = ['modified' => new DateTimeImmutable($start_time)]; + } + if (!\is_null($end_time)) { + $criteria['lt'] = ['modified' => new DateTimeImmutable($end_time)]; + } + + $pins = DB::findBy(Pin::class, $criteria, order_by: ['modified' => 'asc'], offset: $offset, limit: $limit); + return self::respond(self::formatPins($user, $pins)['posts']); + } +} diff --git a/plugins/Pinboard/Entity/Pin.php b/plugins/Pinboard/Entity/Pin.php index 0883fa77d9..fe55653343 100644 --- a/plugins/Pinboard/Entity/Pin.php +++ b/plugins/Pinboard/Entity/Pin.php @@ -5,6 +5,10 @@ declare(strict_types = 1); namespace Plugin\Pinboard\Entity; use App\Core\Entity; +use App\Entity\Actor; +use App\Entity\LocalUser; +use App\Entity\Note; +use Component\Tag\Entity\NoteTag; use DateTimeInterface; class Pin extends Entity @@ -15,6 +19,8 @@ class Pin extends Entity private int $actor_id; private string $url_hash; private string $url; + private string $title; + private ?string $description = null; private bool $replace; private bool $public; private bool $unread; @@ -64,6 +70,28 @@ class Pin extends Entity return $this->url; } + public function setTitle(string $title): self + { + $this->title = $title; + return $this; + } + + public function getTitle(): string + { + return $this->title; + } + + public function setDescription(?string $description): self + { + $this->description = $description; + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + public function setReplace(bool $replace): self { $this->replace = $replace; @@ -111,21 +139,47 @@ class Pin extends Entity // @codeCoverageIgnoreEnd // }}} Autocode + public static function cacheKeys(int|LocalUser|Actor $user): array + { + $id = \is_int($user) ? $user : $user->getId(); + return [ + 'last-modified' => "pinboard-pin-{$id}-last-modified", + ]; + } + + public function getNote(): Note + { + return Note::getById($this->getNoteId()); + } + + /** + * @return NoteTag[] + */ + public function getTags(): array + { + return []; + } + public static function schemaDef(): array { return [ 'name' => 'pinboard_pin', 'fields' => [ - 'note_id' => ['type' => 'int', 'not null' => true, 'description' => 'Id of the note this pin created'], - 'actor_id' => ['type' => 'int', 'not null' => true, 'description' => 'Actor who created this note'], - 'url_hash' => ['type' => 'char', 'length' => 64, 'not null' => true, 'description' => 'Hash of the url, for indexing'], - 'url' => ['type' => 'text', 'not null' => true, 'description' => 'Plain URL this pin refers to (gets rendered in the corresponding note, useful for replace)'], - 'replace' => ['type' => 'bool', 'not null' => true, 'description' => 'Replace any existing bookmark with this URL. Default is yes. If set to no, will throw an error if bookmark exists'], - 'public' => ['type' => 'bool', 'not null' => true, 'description' => 'Whether private or public'], - 'unread' => ['type' => 'bool', 'not null' => true, 'description' => 'Has this been read'], - 'modified' => ['type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'], + 'note_id' => ['type' => 'int', 'not null' => true, 'description' => 'Id of the note this pin created'], + 'actor_id' => ['type' => 'int', 'not null' => true, 'description' => 'Actor who created this note'], + 'url_hash' => ['type' => 'char', 'length' => 64, 'not null' => true, 'description' => 'Hash of the url, for indexing'], + 'url' => ['type' => 'text', 'not null' => true, 'description' => 'Plain URL this pin refers to (gets rendered in the corresponding note, useful for replace)'], + 'title' => ['type' => 'text', 'not null' => true, 'description' => 'Title given to this bookmark'], + 'description' => ['type' => 'text', 'description' => 'Description given to this bookmark'], + 'replace' => ['type' => 'bool', 'not null' => true, 'description' => 'Replace any existing bookmark with this URL. Default is yes. If set to no, will throw an error if bookmark exists'], + 'public' => ['type' => 'bool', 'not null' => true, 'description' => 'Whether private or public'], + 'unread' => ['type' => 'bool', 'not null' => true, 'description' => 'Has this been read'], + 'modified' => ['type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'], ], 'primary key' => ['actor_id', 'url_hash'], + 'indexes' => [ + 'actor_modified_idx' => ['actor_id', 'modified'], + ], ]; } } diff --git a/plugins/Pinboard/Pinboard.php b/plugins/Pinboard/Pinboard.php index 2be31f214e..7a533cc6fd 100644 --- a/plugins/Pinboard/Pinboard.php +++ b/plugins/Pinboard/Pinboard.php @@ -44,13 +44,37 @@ class Pinboard extends Plugin $r->connect( 'pinboard_posts_update', '/pinboard/v1/posts/update', - [C\API::class, 'posts_update'], + [C\APIv1::class, 'posts_update'], ); $r->connect( 'pinboard_posts_add', '/pinboard/v1/posts/add', - [C\API::class, 'posts_add'], + [C\APIv1::class, 'posts_add'], + ); + + $r->connect( + 'pinboard_posts_delete', + '/pinboard/v1/posts/delete', + [C\APIv1::class, 'posts_delete'], + ); + + $r->connect( + 'pinboard_posts_get', + '/pinboard/v1/posts/get', + [C\APIv1::class, 'posts_get'], + ); + + $r->connect( + 'pinboard_posts_recent', + '/pinboard/v1/posts/recent', + [C\APIv1::class, 'posts_recent'], + ); + + $r->connect( + 'pinboard_posts_all', + '/pinboard/v1/posts/all', + [C\APIv1::class, 'posts_all'], ); return Event::next; diff --git a/plugins/Pinboard/templates/pinboard/render.html.twig b/plugins/Pinboard/templates/pinboard/render.html.twig index b6aea66eae..61013b1554 100644 --- a/plugins/Pinboard/templates/pinboard/render.html.twig +++ b/plugins/Pinboard/templates/pinboard/render.html.twig @@ -1,4 +1,5 @@ -
- {# User provided title, do not translate #} - {{ description }} {# Likewise for the description, which may be empty#} +
+ {{ title }} {# User provided title, do not translate #} + {{ url }} +

{{ description }}

{# Likewise for the description, which may be empty #}