diff --git a/plugins/Pinboard/Controller/APIv1.php b/plugins/Pinboard/Controller/APIv1.php index f574ccbb04..31e248d80c 100644 --- a/plugins/Pinboard/Controller/APIv1.php +++ b/plugins/Pinboard/Controller/APIv1.php @@ -29,12 +29,14 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Serializer\Encoder\XmlEncoder; +use Component\Tag\Tag; +use Component\Link\Link; class APIv1 extends Controller { public const SOURCE = 'Pinboard API v1'; - private function before() + private function preCheck() { $format = $this->string('format'); $auth_token = $this->string('auth_token'); @@ -80,13 +82,17 @@ class APIv1 extends Controller return null; } [$id, $token] = explode(':', $input); - return Token::get($id, $token)?->getUser(); + if (filter_var($id, FILTER_VALIDATE_INT) !== false) { + return Token::get((int) $id, $token)?->getUser(); + } else { + return null; + } } private function deleteNoteAndMaybePin(LocalUser $user, Note $note, ?Pin $pin): void { $note->delete($user->getActor(), self::SOURCE); - if (!\is_null($note)) { + if (!\is_null($pin)) { DB::remove($pin); } DB::flush(); @@ -101,6 +107,15 @@ class APIv1 extends Controller return $tags; } + /** + * @param Pin[] $pins + * @param string[] $tags + */ + private function filterByTags(array $pins, array $tags): array + { + return F\select($pins, fn (Pin $pin) => array_intersect(F\map($pin->getTags(), fn (NoteTag $tag) => $tag->getTag()), $tags) !== []); + } + private function getLatestModified(LocalUser $user): string { return Cache::get( @@ -147,12 +162,17 @@ class APIv1 extends Controller /** * Returns the most recent time a bookmark was added, updated or - * deleted. Use this before calling posts/all to see if the data + * deleted. Use this preCheck calling posts/all to see if the data * has changed since the last fetch */ public function posts_update(Request $request) { - $user = self::before(); + $check = self::preCheck(); + if (!$check instanceof LocalUser) { + return $check; + } else { + $user = $check; + } return self::respond(['update_time' => self::getLatestModified($user)]); } @@ -162,7 +182,12 @@ class APIv1 extends Controller */ public function posts_add(Request $request) { - $user = self::before(); + $check = self::preCheck(); + if (!$check instanceof LocalUser) { + return $check; + } else { + $user = $check; + } if (\is_null($url = $this->string('url'))) { throw new ClientException('URL must be provided'); @@ -199,28 +224,33 @@ class APIv1 extends Controller 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); + // Continue below } - } 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::persist($note = Note::create([ + 'actor_id' => $user->getId(), + 'content' => "Bookmark: {$url}\nTitle: {$title}\nDescription: {$description}", + 'content_type' => 'text/plain', + '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' => Pin::note_type, + '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); + foreach ($tags as $tag) { + if (!\is_null($nt = Tag::maybeCreateTag(tag: $tag, note_id: $note->getId(), lang_id: $note->getLanguageId()))) { + DB::persist($nt); + } + } + if (array_values(Link::maybeCreateLink($url, $note->getId())) !== [null, null]) { DB::flush(); $result_code = 'done'; } @@ -233,7 +263,13 @@ class APIv1 extends Controller */ public function posts_delete(Request $request) { - $user = self::before($request); + $check = self::preCheck(); + if (!$check instanceof LocalUser) { + return $check; + } else { + $user = $check; + } + $url = $this->string('url'); if (\is_null($url)) { throw new ClientException('URL must be provided'); @@ -255,16 +291,18 @@ class APIv1 extends Controller */ public function posts_get(Request $request) { - $user = self::before($request); + $check = self::preCheck(); + if (!$check instanceof LocalUser) { + return $check; + } else { + $user = $check; + } + $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)) { @@ -276,6 +314,8 @@ class APIv1 extends Controller throw new BugFoundException('Wonky logic in pinboard/posts/get'); } + $pins = self::filterByTags($pins, $tags); + return self::respond(self::formatPins($user, $pins)); } @@ -284,15 +324,18 @@ class APIv1 extends Controller */ public function posts_recent(Request $request) { - $user = self::before($request); + $check = self::preCheck(); + if (!$check instanceof LocalUser) { + return $check; + } else { + $user = $check; + } + $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); + $pins = self::filterByTags($pins, $tags); return self::respond(self::formatPins($user, $pins)); } @@ -310,7 +353,13 @@ class APIv1 extends Controller */ public function posts_all(Request $request) { - $user = self::before($request); + $check = self::preCheck(); + if (!$check instanceof LocalUser) { + return $check; + } else { + $user = $check; + } + $tags = self::parseTags(); $offset = $this->int('start'); $limit = $this->int('results'); @@ -318,10 +367,6 @@ class APIv1 extends Controller $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)]; @@ -331,6 +376,37 @@ class APIv1 extends Controller } $pins = DB::findBy(Pin::class, $criteria, order_by: ['modified' => 'asc'], offset: $offset, limit: $limit); + $pins = self::filterByTags($pins, $tags); + return self::respond(self::formatPins($user, $pins)['posts']); } + + /** + * Returns a full list of the user's tags along with the number of times they were used + */ + public function tags_get(Request $request) + { + $check = self::preCheck(); + if (!$check instanceof LocalUser) { + return $check; + } else { + $user = $check; + } + + $tags_freq = []; + foreach ($user->getActor()->getNoteTags(Pin::note_type) as $tag) { + if (!isset($tags_freq[$tag->getCanonical()])) { + $tags_freq[$tag->getCanonical()] = []; + } + $tags_freq[$tag->getCanonical()][] = $tag->getTag(); + } + + foreach ($tags_freq as $canon => $variations) { + $freqs = array_count_values($variations); + arsort($freqs); + $tags_freq[$canon] = $freqs[array_key_first($freqs)]; + } + + return self::respond($tags_freq); + } } diff --git a/plugins/Pinboard/Entity/Pin.php b/plugins/Pinboard/Entity/Pin.php index fe55653343..b873761f57 100644 --- a/plugins/Pinboard/Entity/Pin.php +++ b/plugins/Pinboard/Entity/Pin.php @@ -139,6 +139,8 @@ class Pin extends Entity // @codeCoverageIgnoreEnd // }}} Autocode + public const note_type = 'page'; + public static function cacheKeys(int|LocalUser|Actor $user): array { $id = \is_int($user) ? $user : $user->getId(); @@ -157,7 +159,7 @@ class Pin extends Entity */ public function getTags(): array { - return []; + return Note::getById($this->getNoteId())->getTags(); } public static function schemaDef(): array diff --git a/plugins/Pinboard/Entity/Token.php b/plugins/Pinboard/Entity/Token.php index 9de79a0ec6..701ef2a508 100644 --- a/plugins/Pinboard/Entity/Token.php +++ b/plugins/Pinboard/Entity/Token.php @@ -95,7 +95,7 @@ class Token extends Entity options: ['limit' => 1], ), ); - } elseif (!is_id($id) && !\is_null($token)) { + } elseif (!\is_null($id) && !\is_null($token)) { return Cache::get( self::cacheKeys($id)['user-token'], fn () => DB::dql( diff --git a/plugins/Pinboard/Pinboard.php b/plugins/Pinboard/Pinboard.php index 29bf6a66c3..7e0fdc0d51 100644 --- a/plugins/Pinboard/Pinboard.php +++ b/plugins/Pinboard/Pinboard.php @@ -87,6 +87,12 @@ class Pinboard extends Plugin [C\APIv1::class, 'posts_all'], ); + $r->connect( + 'pinboard_tags_get', + '/pinboard/v1/tags/get', + [C\APIv1::class, 'tags_get'], + ); + return Event::next; }