*/ class BookmarkMapper extends QBMapper { /** @var IConfig */ private $config; /** @var IEventDispatcher */ private $eventDispatcher; /** @var UrlNormalizer */ private $urlNormalizer; /** * @var int */ private $limit; /** * @var PublicFolderMapper */ private $publicMapper; /** * @var IQueryBuilder */ private $deleteTagsQuery; /** * @var IQueryBuilder */ private $findByUrlQuery; /** * @var ITimeFactory */ private $time; /** * @var FolderMapper */ private $folderMapper; /** * @var ShareMapper */ private $shareMapper; /** * BookmarkMapper constructor. * * @param IDBConnection $db * @param IEventDispatcher $eventDispatcher * @param UrlNormalizer $urlNormalizer * @param IConfig $config * @param PublicFolderMapper $publicMapper * @param ITimeFactory $timeFactory * @param FolderMapper $folderMapper * @param ShareMapper $shareMapper */ public function __construct(IDBConnection $db, IEventDispatcher $eventDispatcher, UrlNormalizer $urlNormalizer, IConfig $config, PublicFolderMapper $publicMapper, ITimeFactory $timeFactory, \OCA\Bookmarks\Db\FolderMapper $folderMapper, \OCA\Bookmarks\Db\ShareMapper $shareMapper) { parent::__construct($db, 'bookmarks', Bookmark::class); $this->eventDispatcher = $eventDispatcher; $this->urlNormalizer = $urlNormalizer; $this->config = $config; $this->limit = (int)$config->getAppValue('bookmarks', 'performance.maxBookmarksperAccount', 0); $this->publicMapper = $publicMapper; $this->deleteTagsQuery = $this->getDeleteTagsQuery(); $this->findByUrlQuery = $this->getFindByUrlQuery(); $this->time = $timeFactory; $this->folderMapper = $folderMapper; $this->shareMapper = $shareMapper; } protected function getFindByUrlQuery(): IQueryBuilder { $qb = $this->db->getQueryBuilder(); $qb ->select('*') ->from('bookmarks') ->where($qb->expr()->eq('user_id', $qb->createParameter('user_id'))) ->andWhere($qb->expr()->eq('url', $qb->createParameter('url'))); return $qb; } protected function getDeleteTagsQuery(): IQueryBuilder { $qb = $this->db->getQueryBuilder(); $qb ->delete('bookmarks_tags') ->where($qb->expr()->eq('bookmark_id', $qb->createParameter('id'))); return $qb; } /** * @param $userId * @param $url * @return Bookmark * @throws DoesNotExistException * @throws MultipleObjectsReturnedException */ public function findByUrl($userId, $url): Bookmark { $qb = $this->findByUrlQuery; $qb->setParameters([ 'user_id' => $userId, 'url' => $url ]); return $this->findEntity($qb); } /** * @param string $userId * @throws DoesNotExistException * @throws MultipleObjectsReturnedException */ public function deleteAll(string $userId): void { $qb = $this->db->getQueryBuilder(); $qb->select('b.id') ->from('bookmarks', 'b') ->where($qb->expr()->eq('b.user_id', $qb->createPositionalParameter($userId))); $orphanedBookmarks = $qb->execute(); while ($bookmark = $orphanedBookmarks->fetchColumn()) { $bm = $this->find($bookmark); $this->delete($bm); } } /** * Magic to use BookmarkWithTags if possible * @param array $row * @return Bookmark */ protected function mapRowToEntity(array $row): Bookmark { $hasTags = false; foreach (array_keys($row) as $field) { if (preg_match('#.*tag|folder.*#i', $field, $matches) === 1) { // 1 means it matches, 0 means it doesn't. $hasTags = true; break; } } if ($hasTags !== false) { return BookmarkWithTagsAndParent::fromRow($row); } return call_user_func($this->entityClass .'::fromRow', $row); } /** * Find a specific bookmark by Id * * @param int $id * @return Bookmark * @throws DoesNotExistException if not found * @throws MultipleObjectsReturnedException if more than one result */ public function find(int $id): Bookmark { $qb = $this->db->getQueryBuilder(); $qb ->select(Bookmark::$columns) ->from('bookmarks') ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); return $this->findEntity($qb); } /** * @param string $userId * @param QueryParameters $queryParams * * @return Bookmark[] * * @throws UrlParseError * @throws \OC\DB\Exceptions\DbalException * @throws Exception */ public function findAll(string $userId, QueryParameters $queryParams, bool $withGroupBy = true): array { $rootFolder = $this->folderMapper->findRootFolder($userId); // gives us all bookmarks in this folder, recursively [$cte, $params, $paramTypes] = $this->_generateCTE($rootFolder->getId()); $qb = $this->db->getQueryBuilder(); $bookmark_cols = array_map(static function ($c) { return 'b.' . $c; }, Bookmark::$columns); $qb->select($bookmark_cols); $qb->groupBy($bookmark_cols); if ($withGroupBy) { $this->_selectFolders($qb); $this->_selectTags($qb); } $qb->automaticTablePrefix(false); $qb ->from('*PREFIX*bookmarks', 'b') ->join('b', 'folder_tree', 'tree', 'tree.item_id = b.id AND tree.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK)); $this->_filterUrl($qb, $queryParams); $this->_filterArchived($qb, $queryParams); $this->_filterUnavailable($qb, $queryParams); $this->_filterDuplicated($qb, $queryParams); $this->_filterFolder($qb, $queryParams); $this->_filterTags($qb, $queryParams); $this->_filterUntagged($qb, $queryParams); $this->_filterSearch($qb, $queryParams); $this->_sortAndPaginate($qb, $queryParams); $finalQuery = $cte . ' ' . $qb->getSQL(); $params = array_merge($params, $qb->getParameters()); $paramTypes = array_merge($paramTypes, $qb->getParameterTypes()); return $this->findEntitiesWithRawQuery($finalQuery, $params, $paramTypes); } /** * @throws \OCP\DB\Exception */ protected function findEntitiesWithRawQuery(string $query, array $params, array $types) { $cursor = $this->db->executeQuery($query, $params, $types); $entities = []; while ($row = $cursor->fetch()) { $entities[] = $this->mapRowToEntity($row); } $cursor->closeCursor(); return $entities; } /** * Common table expression that lists all items in a given folder, recursively * @param int $folderId * @return array */ private function _generateCTE(int $folderId) : array { // The base case of the recursion is just the folder we're given $baseCase = $this->db->getQueryBuilder(); $baseCase ->selectAlias($baseCase->createFunction($this->getDbType() === 'mysql'? 'cast('.$baseCase->createPositionalParameter($folderId, IQueryBuilder::PARAM_INT).' as UNSIGNED)' : 'cast('.$baseCase->createPositionalParameter($folderId, IQueryBuilder::PARAM_INT).' as BIGINT)'), 'item_id') ->selectAlias($baseCase->createFunction($this->getDbType() === 'mysql'? 'cast(0 as UNSIGNED)' : 'cast(0 as BIGINT)'), 'parent_folder') ->selectAlias($baseCase->createFunction($this->getDbType() === 'mysql'? 'cast('.$baseCase->createPositionalParameter(TreeMapper::TYPE_FOLDER).' as CHAR(20))' : 'cast('.$baseCase->createPositionalParameter(TreeMapper::TYPE_FOLDER).' as TEXT)'), 'type') ->selectAlias($baseCase->createFunction($this->getDbType() === 'mysql'? 'cast(0 as UNSIGNED)' : 'cast(0 as BIGINT)'), 'idx'); // The first recursive case lists all children of folders we've already found $recursiveCase = $this->db->getQueryBuilder(); $recursiveCase->automaticTablePrefix(false); $recursiveCase ->selectAlias('tr.id', 'item_id') ->selectAlias('tr.parent_folder', 'parent_folder') ->selectAlias('tr.type', 'type') ->selectAlias('tr.index', 'idx') ->from('*PREFIX*bookmarks_tree', 'tr') ->join('tr', $this->getDbType() === 'mysql'? 'folder_tree' : 'inner_folder_tree', 'e', 'e.item_id = tr.parent_folder AND e.type = '.$recursiveCase->createPositionalParameter(TreeMapper::TYPE_FOLDER)); // The second recursive case lists all children of shared folders we've already found $recursiveCaseShares = $this->db->getQueryBuilder(); $recursiveCaseShares->automaticTablePrefix(false); $recursiveCaseShares ->selectAlias('s.folder_id', 'item_id') ->addSelect('e.parent_folder') ->selectAlias($recursiveCaseShares->createFunction($recursiveCaseShares->createPositionalParameter(TreeMapper::TYPE_FOLDER)), 'type') ->selectAlias('e.idx', 'idx') ->from(($this->getDbType() === 'mysql'? 'folder_tree' : 'second_folder_tree'), 'e') ->join('e', '*PREFIX*bookmarks_shared_folders', 's', 's.id = e.item_id AND e.type = '.$recursiveCaseShares->createPositionalParameter(TreeMapper::TYPE_SHARE)); if ($this->getDbType() === 'mysql') { // For mysql we can just throw these three queries together in a CTE $withRecursiveQuery = 'WITH RECURSIVE folder_tree(item_id, parent_folder, type, idx) AS ( ' . $baseCase->getSQL() . ' UNION ALL ' . $recursiveCase->getSQL() . ' UNION ALL ' . $recursiveCaseShares->getSQL() . ')'; } else { // Postgres loves us dearly and doesn't allow two recursive references in one CTE, aaah. // So we nest them: $secondBaseCase = $this->db->getQueryBuilder(); $secondBaseCase->automaticTablePrefix(false); $secondBaseCase ->select('item_id', 'parent_folder', 'type', 'idx') ->from('inner_folder_tree'); $thirdBaseCase = $this->db->getQueryBuilder(); $thirdBaseCase->automaticTablePrefix(false); $thirdBaseCase ->select('item_id', 'parent_folder', 'type', 'idx') ->from('second_folder_tree'); $secondRecursiveCase = $this->db->getQueryBuilder(); $secondRecursiveCase->automaticTablePrefix(false); $secondRecursiveCase ->selectAlias('tr.id', 'item_id') ->selectAlias('tr.parent_folder', 'parent_folder') ->selectAlias('tr.type', 'type') ->selectAlias('tr.index', 'idx') ->from('*PREFIX*bookmarks_tree', 'tr') ->join('tr', 'folder_tree', 'e', 'e.item_id = tr.parent_folder AND e.type = '.$secondRecursiveCase->createPositionalParameter(TreeMapper::TYPE_FOLDER)); // First the base case together with the normal recurisve case // Then the second helper base case together with the recursive shares case // then we need another instance of the first recursive case, duplicated here as secondRecursive case // to recurse into child folders of shared folders // Note: This doesn't cover cases where a shared folder is inside a shared folder. $withRecursiveQuery = 'WITH RECURSIVE folder_tree(item_id, parent_folder, type, idx) AS ( ' . 'WITH RECURSIVE second_folder_tree(item_id, parent_folder, type, idx) AS (' . 'WITH RECURSIVE inner_folder_tree(item_id, parent_folder, type, idx) AS ( ' . $baseCase->getSQL() . ' UNION ALL ' . $recursiveCase->getSQL() . ')' . ' ' . $secondBaseCase->getSQL() . ' UNION ALL '. $recursiveCaseShares->getSQL() .')'. ' ' . $thirdBaseCase->getSQL() . ' UNION ALL ' . $secondRecursiveCase->getSQL(). ')'; } // Now we need to concatenate the params of all these queries for downstream assembly of the greater query if ($this->getDbType() === 'mysql') { $params = array_merge($baseCase->getParameters(), $recursiveCase->getParameters(), $recursiveCaseShares->getParameters()); $paramTypes = array_merge($baseCase->getParameterTypes(), $recursiveCase->getParameterTypes(), $recursiveCaseShares->getParameterTypes()); } else { $params = array_merge($baseCase->getParameters(), $recursiveCase->getParameters(), $secondBaseCase->getParameters(), $recursiveCaseShares->getParameters(), $thirdBaseCase->getParameters(), $secondRecursiveCase->getParameters()); $paramTypes = array_merge($baseCase->getParameterTypes(), $recursiveCase->getParameterTypes(), $secondBaseCase->getParameterTypes(), $recursiveCaseShares->getParameterTypes(), $thirdBaseCase->getParameterTypes(), $secondRecursiveCase->getParameterTypes()); } return [$withRecursiveQuery, $params, $paramTypes]; } private function _sortAndPaginate(IQueryBuilder $qb, QueryParameters $params): void { $sqlSortColumn = $params->getSortBy('lastmodified', $this->getSortByColumns()); if ($sqlSortColumn === 'title') { $qb->addOrderBy($qb->createFunction('UPPER(`b`.`title`)'), 'ASC'); } elseif ($sqlSortColumn === 'index') { $qb->addOrderBy('tree.idx', 'ASC'); $qb->addGroupBy('tree.idx'); } elseif ($sqlSortColumn === 'url') { $qb->addOrderBy('b.url', 'ASC'); } else { $qb->addOrderBy('b.'.$sqlSortColumn, 'DESC'); } // Always sort by id additionally, so the ordering is stable $qb->addOrderBy('b.id', 'ASC'); $qb->addGroupBy('b.id'); if ($params->getLimit() !== -1) { $qb->setMaxResults($params->getLimit()); } if ($params->getOffset() !== 0) { $qb->setFirstResult($params->getOffset()); } } /** * @throws UrlParseError */ private function _filterUrl(IQueryBuilder $qb, QueryParameters $params): void { if (($url = $params->getUrl()) !== null) { $normalized = $this->urlNormalizer->normalize($url); $qb->andWhere($qb->expr()->eq('b.url', $qb->createPositionalParameter($normalized))); } } /** * @param IQueryBuilder $qb * @param QueryParameters $params */ private function _filterSearch(IQueryBuilder $qb, QueryParameters $params): void { $connectWord = 'AND'; if ($params->getConjunction() === 'or') { $connectWord = 'OR'; } $filters = $params->getSearch(); if (count($filters) === 0) { return; } $tagsCol = $this->_getTagsColumn($qb); $filterExpressions = []; $otherColumns = ['b.url', 'b.title', 'b.description', 'b.text_content']; foreach ($filters as $filter) { $expr = []; $expr[] = $qb->expr()->iLike($tagsCol, $qb->createPositionalParameter('%' . $this->db->escapeLikeParameter($filter) . '%')); foreach ($otherColumns as $col) { $expr[] = $qb->expr()->iLike( $qb->createFunction($qb->getColumnName($col)), $qb->createPositionalParameter('%' . $this->db->escapeLikeParameter(strtolower($filter)) . '%') ); } $filterExpressions[] = call_user_func_array([$qb->expr(), 'orX'], $expr); } if ($connectWord === 'AND') { $filterExpression = call_user_func_array([$qb->expr(), 'andX'], $filterExpressions); } else { $filterExpression = call_user_func_array([$qb->expr(), 'orX'], $filterExpressions); } $qb->andHaving($filterExpression); } /** * @param IQueryBuilder $qb * @param QueryParameters $params */ private function _filterArchived(IQueryBuilder $qb, QueryParameters $params): void { if ($params->getArchived()) { $qb->andWhere($qb->expr()->isNotNull('b.archived_file')); } } /** * @param IQueryBuilder $qb * @param QueryParameters $params */ private function _filterUnavailable(IQueryBuilder $qb, QueryParameters $params): void { if ($params->getUnavailable()) { $qb->andWhere($qb->expr()->eq('b.available', $qb->createPositionalParameter(false, IQueryBuilder::PARAM_BOOL))); } } /** * @param IQueryBuilder $qb * @param QueryParameters $params * @return void */ private function _filterDuplicated(IQueryBuilder $qb, QueryParameters $params) { if ($params->getDuplicated()) { $subQuery = $this->db->getQueryBuilder(); $subQuery->select('trdup.parent_folder') ->from('*PREFIX*bookmarks_tree', 'trdup') ->where($subQuery->expr()->eq('b.id', 'trdup.id')) ->andWhere($subQuery->expr()->neq('trdup.parent_folder', 'tree.parent_folder')) ->andWhere($subQuery->expr()->eq('trdup.type', $qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK))); $qb->andWhere($qb->createFunction('EXISTS('.$subQuery->getSQL().')')); } } /** * @param IQueryBuilder $qb * @param QueryParameters $params */ private function _filterFolder(IQueryBuilder $qb, QueryParameters $params): void { if ($params->getFolder() !== null) { $qb->andWhere($qb->expr()->eq('tree.parent_folder', $qb->createPositionalParameter($params->getFolder(), IQueryBuilder::PARAM_INT))); } } /** * @param IQueryBuilder $qb */ private function _selectTags(IQueryBuilder $qb): void { $qb->leftJoin('b', '*PREFIX*bookmarks_tags', 't', $qb->expr()->eq('t.bookmark_id', 'b.id')); $qb->selectAlias($this->_getTagsColumn($qb), 'tags'); } /** * @param IQueryBuilder $qb * @param QueryParameters $params */ private function _filterUntagged(IQueryBuilder $qb, QueryParameters $params): void { if ($params->getUntagged()) { $qb->andWhere($qb->expr()->isNull('t.bookmark_id')); } } /** * @param IQueryBuilder $qb * @param QueryParameters $params */ private function _filterTags(IQueryBuilder $qb, QueryParameters $params): void { if (count($params->getTags())) { foreach ($params->getTags() as $i => $tag) { $qb->leftJoin('b', '*PREFIX*bookmarks_tags', 'tg'.$i, $qb->expr()->eq('tg'.$i.'.bookmark_id', 'b.id')); $qb->andWhere($qb->expr()->eq('tg'.$i.'.tag', $qb->createPositionalParameter($tag))); } } } /** * @param string $userId * @return int */ public function countArchived(string $userId): int { $qb = $this->db->getQueryBuilder(); $qb->selectAlias($qb->func()->count('b.id'), 'count'); $qb ->from('bookmarks', 'b') ->leftJoin('b', 'bookmarks_tree', 'tr', 'b.id = tr.id AND tr.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK)) ->leftJoin('tr', 'bookmarks_shared_folders', 'sf', $qb->expr()->eq('tr.parent_folder', 'sf.folder_id')) ->where( $qb->expr()->andX( $qb->expr()->orX( $qb->expr()->eq('b.user_id', $qb->createPositionalParameter($userId)), $qb->expr()->eq('sf.user_id', $qb->createPositionalParameter($userId)) ), $qb->expr()->in('b.user_id', array_map([$qb, 'createPositionalParameter'], array_merge($this->_findSharersFor($userId), [$userId]))) ) ) ->andWhere($qb->expr()->isNotNull('b.archived_file')); return $qb->execute()->fetch(PDO::FETCH_COLUMN); } /** * @param string $userId * @return int */ public function countUnavailable(string $userId): int { $qb = $this->db->getQueryBuilder(); $qb->selectAlias($qb->func()->count('b.id'), 'count'); $qb ->from('bookmarks', 'b') ->leftJoin('b', 'bookmarks_tree', 'tr', 'b.id = tr.id AND tr.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK)) ->leftJoin('tr', 'bookmarks_shared_folders', 'sf', $qb->expr()->eq('tr.parent_folder', 'sf.folder_id')) ->where( $qb->expr()->andX( $qb->expr()->orX( $qb->expr()->eq('b.user_id', $qb->createPositionalParameter($userId)), $qb->expr()->eq('sf.user_id', $qb->createPositionalParameter($userId)) ), $qb->expr()->in('b.user_id', array_map([$qb, 'createPositionalParameter'], array_merge($this->_findSharersFor($userId), [$userId]))) ) ) ->andWhere($qb->expr()->eq('b.available', $qb->createPositionalParameter(false, IQueryBuilder::PARAM_BOOL))); return $qb->execute()->fetch(PDO::FETCH_COLUMN); } /** * @param string $userId * @return int */ public function countDuplicated(string $userId): int { $qb = $this->db->getQueryBuilder(); $qb->selectDistinct($qb->func()->count('b.id')); $qb ->from('bookmarks', 'b') ->leftJoin('b', 'bookmarks_tree', 'tr', 'b.id = tr.id AND tr.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK)) ->leftJoin('tr', 'bookmarks_shared_folders', 'sf', $qb->expr()->eq('tr.parent_folder', 'sf.folder_id')) ->where( $qb->expr()->andX( $qb->expr()->orX( $qb->expr()->eq('b.user_id', $qb->createPositionalParameter($userId)), $qb->expr()->eq('sf.user_id', $qb->createPositionalParameter($userId)) ), $qb->expr()->in('b.user_id', array_map([$qb, 'createPositionalParameter'], array_merge($this->_findSharersFor($userId), [$userId]))) ) ); $subQuery = $this->db->getQueryBuilder(); $subQuery->select('trdup.parent_folder') ->from('bookmarks_tree', 'trdup') ->where($subQuery->expr()->eq('b.id', 'trdup.id')) ->andWhere($subQuery->expr()->neq('trdup.parent_folder', 'tr.parent_folder')) ->andWhere($subQuery->expr()->eq('trdup.type', $qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK))); $qb->andWhere($qb->createFunction('EXISTS('.$subQuery->getSQL().')')); return $qb->execute()->fetch(PDO::FETCH_COLUMN); } /** * @param string $token * @param QueryParameters $queryParams * * * @return Bookmark[] * * @throws DoesNotExistException * @throws MultipleObjectsReturnedException * @throws Exception * * @psalm-return array */ public function findAllInPublicFolder(string $token, QueryParameters $queryParams, $withGroupBy = true): array { /** @var PublicFolder $publicFolder */ $publicFolder = $this->publicMapper->find($token); /** @var Folder $folder */ $folder = $this->folderMapper->find($publicFolder->getFolderId()); // gives us all bookmarks in this folder, recursively [$cte, $params, $paramTypes] = $this->_generateCTE($folder->getId()); $qb = $this->db->getQueryBuilder(); $qb->automaticTablePrefix(false); $bookmark_cols = array_map(static function ($c) { return 'b.' . $c; }, Bookmark::$columns); $qb->select($bookmark_cols); $qb->groupBy($bookmark_cols); if ($withGroupBy) { $this->_selectFolders($qb); $this->_selectTags($qb); } $qb ->from('*PREFIX*bookmarks', 'b') ->join('b', 'folder_tree', 'tree', 'tree.item_id = b.id AND tree.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK)); $this->_filterUrl($qb, $queryParams); $this->_filterArchived($qb, $queryParams); $this->_filterUnavailable($qb, $queryParams); $this->_filterDuplicated($qb, $queryParams); $this->_filterFolder($qb, $queryParams); $this->_filterTags($qb, $queryParams); $this->_filterUntagged($qb, $queryParams); $this->_filterSearch($qb, $queryParams); $this->_sortAndPaginate($qb, $queryParams); $finalQuery = $cte . ' '. $qb->getSQL(); $params = array_merge($params, $qb->getParameters()); $paramTypes = array_merge($paramTypes, $qb->getParameterTypes()); return $this->findEntitiesWithRawQuery($finalQuery, $params, $paramTypes); } /** * @param int $limit * @param int $stalePeriod * * @return Bookmark[] * * @psalm-return array */ public function findPendingPreviews(int $limit, int $stalePeriod): array { $qb = $this->db->getQueryBuilder(); $qb->select(Bookmark::$columns); $qb->from('bookmarks', 'b'); $qb->where($qb->expr()->lt('last_preview', $qb->createPositionalParameter($this->time->getTime() - $stalePeriod))); $qb->orWhere($qb->expr()->isNull('last_preview')); $qb->setMaxResults($limit); return $this->findEntities($qb); } /** * @psalm-param Bookmark $entity * @param Entity $entity * * @return Bookmark * @psalm-return Bookmark */ public function delete(Entity $entity): Bookmark { $this->eventDispatcher->dispatch( BeforeDeleteEvent::class, new BeforeDeleteEvent(TreeMapper::TYPE_BOOKMARK, $entity->getId()) ); $returnedEntity = parent::delete($entity); $id = $entity->getId(); $qb = $this->deleteTagsQuery; $qb->setParameter('id', $id); $qb->execute(); return $returnedEntity; } /** * @psalm-param Bookmark $entity * @param Entity $entity * @return Bookmark * @throws UrlParseError */ public function update(Entity $entity): Bookmark { // normalize url if ($entity->isWebLink()) { $entity->setUrl($this->urlNormalizer->normalize($entity->getUrl())); } $entity->setLastmodified(time()); return parent::update($entity); } /** * @psalm-param Bookmark $entity * @param Entity $entity * @return Bookmark * @throws AlreadyExistsError * @throws UrlParseError * @throws UserLimitExceededError */ public function insert(Entity $entity): Bookmark { // Enforce user limit if ($this->limit > 0 && $this->limit <= $this->countBookmarksOfUser($entity->getUserId())) { throw new UserLimitExceededError('Exceeded user limit of ' . $this->limit . ' bookmarks'); } // normalize url if ($entity->isWebLink()) { $entity->setUrl($this->urlNormalizer->normalize($entity->getUrl())); } if ($entity->getAdded() === null) { $entity->setAdded(time()); } $entity->setLastmodified(time()); $entity->setLastPreview(0); $entity->setClickcount(0); try { $this->findByUrl($entity->getUserId(), $entity->getUrl()); } catch (DoesNotExistException $e) { parent::insert($entity); return $entity; } catch (MultipleObjectsReturnedException $e) { // noop } throw new AlreadyExistsError('A bookmark with this URL already exists'); } /** * @psalm-param Bookmark $entity * @param Entity $entity * @return Bookmark * @throws AlreadyExistsError * @throws UrlParseError * @throws UserLimitExceededError|MultipleObjectsReturnedException */ public function insertOrUpdate(Entity $entity): Bookmark { try { $newEntity = $this->insert($entity); } catch (AlreadyExistsError $e) { $bookmark = $this->findByUrl($entity->getUserId(), $entity->getUrl()); $entity->setId($bookmark->getId()); $newEntity = $this->update($entity); } return $newEntity; } /** * @param $userId * @param string $userId * * @return int */ public function countBookmarksOfUser(string $userId) : int { $qb = $this->db->getQueryBuilder(); $qb ->select($qb->func()->count('id')) ->from('bookmarks') ->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId))); return $qb->execute()->fetch(PDO::FETCH_COLUMN); } /** * Returns the list of possible sort by columns. * * @return string[] */ private function getSortByColumns(): array { $treeFields = [ 'index', ]; return array_merge(Bookmark::$columns, $treeFields); } /** * @return string */ private function getDbType(): string { return $this->config->getSystemValue('dbtype', 'sqlite'); } /** * @param IQueryBuilder $qb */ private function _selectFolders(IQueryBuilder $qb): void { $qb->leftJoin('b', '*PREFIX*bookmarks_tree', 'tr2', 'b.id = tr2.id AND tr2.type = '.$qb->createPositionalParameter(TreeMapper::TYPE_BOOKMARK)); if ($this->getDbType() === 'pgsql') { $folders = $qb->createFunction('array_to_string(array_agg(' . $qb->getColumnName('tr2.parent_folder') . "), ',')"); } else { $folders = $qb->createFunction('GROUP_CONCAT(' . $qb->getColumnName('tr2.parent_folder') . ')'); } $qb->selectAlias($folders, 'folders'); } /** * @param IQueryBuilder $qb * @return IQueryFunction */ private function _getTagsColumn(IQueryBuilder $qb) : IQueryFunction { $dbType = $this->getDbType(); if ($dbType === 'pgsql') { $tagsCol = $qb->createFunction('array_to_string(array_agg(' . $qb->getColumnName('t.tag') . "), ',')"); } else { $tagsCol = $qb->createFunction('IFNULL(GROUP_CONCAT(' . $qb->getColumnName('t.tag') . '), "")'); } return $tagsCol; } /** * @param string $userId * @return string[] */ private function _findSharersFor(string $userId) :array { return array_map(static function (Share $share) { return $share->getOwner(); }, $this->shareMapper->findByUser($userId)); } }