Feature: Show and count duplicates

Signed-off-by: Marcel Klehr <mklehr@gmx.net>
This commit is contained in:
Marcel Klehr 2022-03-08 13:17:33 +01:00
parent 3903176ed5
commit cea9fe8477
11 changed files with 173 additions and 12 deletions

View File

@ -30,6 +30,7 @@ return [
['name' => 'web_view#index', 'url' => '/untagged', 'verb' => 'GET', 'postfix' => 'untagged'],
['name' => 'web_view#index', 'url' => '/unavailable', 'verb' => 'GET', 'postfix' => 'unavailable'],
['name' => 'web_view#index', 'url' => '/archived', 'verb' => 'GET', 'postfix' => 'archived'],
['name' => 'web_view#index', 'url' => '/duplicated', 'verb' => 'GET', 'postfix' => 'duplicated'],
['name' => 'web_view#index', 'url' => '/shared', 'verb' => 'GET', 'postfix' => 'shared'],
['name' => 'web_view#index', 'url' => '/bookmarklet', 'verb' => 'GET', 'postfix' => 'bookmarklet'],
['name' => 'web_view#service_worker', 'url' => '/service-worker.js', 'verb' => 'GET'],
@ -43,6 +44,7 @@ return [
['name' => 'internal_bookmark#import_bookmark', 'url' => '/bookmark/import', 'verb' => 'POST'],
['name' => 'internal_bookmark#count_unavailable', 'url' => '/bookmark/unavailable', 'verb' => 'GET'],
['name' => 'internal_bookmark#count_archived', 'url' => '/bookmark/archived', 'verb' => 'GET'],
['name' => 'internal_bookmark#count_duplicated', 'url' => '/bookmark/duplicated', 'verb' => 'GET'],
['name' => 'internal_bookmark#edit_bookmark', 'url' => '/bookmark/{id}', 'verb' => 'PUT'],
['name' => 'internal_bookmark#get_single_bookmark', 'url' => '/bookmark/{id}', 'verb' => 'GET'],
['name' => 'internal_bookmark#delete_bookmark', 'url' => '/bookmark/{id}', 'verb' => 'DELETE'],

View File

@ -264,6 +264,7 @@ class BookmarkController extends ApiController {
* @param string|null $url
* @param bool|null $unavailable
* @param bool|null $archived
* @param bool|null $duplicated
* @return DataResponse
*
* @NoAdminRequired
@ -282,7 +283,8 @@ class BookmarkController extends ApiController {
?int $folder = null,
?string $url = null,
?bool $unavailable = null,
?bool $archived = null
?bool $archived = null,
?bool $duplicated = null
): DataResponse {
$this->registerResponder('rss', function (DataResponse $res) {
if ($res->getData()['status'] === 'success') {
@ -337,6 +339,9 @@ class BookmarkController extends ApiController {
if ($archived !== null) {
$params->setArchived($archived);
}
if ($duplicated !== null) {
$params->setDuplicated($duplicated);
}
$params->setTags($filterTag);
$params->setSearch($search);
$params->setConjunction($conjunction);
@ -778,6 +783,22 @@ class BookmarkController extends ApiController {
return new JSONResponse(['status' => 'success', 'item' => $count]);
}
/**
* @return JSONResponse
* @NoAdminRequired
* @NoCSRFRequired
* @CORS
* @PublicPage
*/
public function countDuplicated(): JSONResponse {
if (!Authorizer::hasPermission(Authorizer::PERM_READ, $this->authorizer->getPermissionsForFolder(-1, $this->request))) {
return new JSONResponse(['status' => 'error', 'data' => 'Unauthorized'], Http::STATUS_FORBIDDEN);
}
$count = $this->bookmarkMapper->countDuplicated($this->authorizer->getUserId());
return new JSONResponse(['status' => 'success', 'item' => $count]);
}
/**
* @return JSONResponse
* @NoAdminRequired

View File

@ -58,6 +58,7 @@ class InternalBookmarkController extends ApiController {
* @param string|null $url
* @param bool|null $unavailable
* @param bool|null $archived
* @param bool|null $duplicated
* @return DataResponse
*
* @NoAdminRequired
@ -73,9 +74,10 @@ class InternalBookmarkController extends ApiController {
$folder = null,
$url = null,
$unavailable = null,
$archived = null
$archived = null,
$duplicated = null
): DataResponse {
return $this->publicController->getBookmarks($page, $tags, $conjunction, $sortby, $search, $limit, $untagged, $folder, $url, $unavailable, $archived);
return $this->publicController->getBookmarks($page, $tags, $conjunction, $sortby, $search, $limit, $untagged, $folder, $url, $unavailable, $archived, $duplicated);
}
/**
@ -233,6 +235,17 @@ class InternalBookmarkController extends ApiController {
return $this->publicController->countArchived();
}
/**
*
* @return JSONResponse
*
* @NoAdminRequired
* @NoCSRFRequired
*/
public function countDuplicated(): JSONResponse {
return $this->publicController->countDuplicated();
}
/**
* @return JSONResponse
* @NoAdminRequired

View File

@ -233,6 +233,7 @@ class BookmarkMapper extends QBMapper {
$this->_filterUrl($qb, $params);
$this->_filterArchived($qb, $params);
$this->_filterUnavailable($qb, $params);
$this->_filterDuplicated($qb, $params);
$this->_filterFolder($qb, $params);
$this->_filterTags($qb, $params);
$this->_filterUntagged($qb, $params);
@ -361,6 +362,23 @@ class BookmarkMapper extends QBMapper {
}
}
/**
* @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('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().')'));
}
}
/**
* @param IQueryBuilder $qb
* @param QueryParameters $params
@ -456,8 +474,38 @@ class BookmarkMapper extends QBMapper {
}
/**
* *
*
* @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 $params
*
@ -505,6 +553,7 @@ class BookmarkMapper extends QBMapper {
$this->_filterUrl($qb, $params);
$this->_filterArchived($qb, $params);
$this->_filterUnavailable($qb, $params);
$this->_filterDuplicated($qb, $params);
$this->_filterFolder($qb, $params);
$this->_filterTags($qb, $params);
$this->_filterUntagged($qb, $params);

View File

@ -22,6 +22,7 @@ class QueryParameters {
private $untagged = false;
private $unavailable = false;
private $archived = false;
private $duplicated = false;
private $search = [];
private $tags = [];
@ -223,4 +224,21 @@ class QueryParameters {
$this->untagged = $untagged;
return $this;
}
/**
* @param bool $duplicated
*
* @return static
*/
public function setDuplicated(bool $duplicated): self {
$this->duplicated = $duplicated;
return $this;
}
/**
* @return bool
*/
public function getDuplicated(): bool {
return $this->duplicated;
}
}

View File

@ -21,6 +21,15 @@
:title="t('bookmarks', 'Recent')">
<HistoryIcon slot="icon" :size="18" :fill-color="colorMainText" />
</AppNavigationItem>
<AppNavigationItem
key="menu-shared-folders"
:to="{ name: routes.SHARED_FOLDERS }"
:title="t('bookmarks', 'Shared with you')">
<ShareVariantIcon slot="icon" :size="18" :fill-color="colorMainText" />
<AppNavigationCounter v-show="Boolean(sharedFoldersCount)" slot="counter">
{{ sharedFoldersCount }}
</AppNavigationCounter>
</AppNavigationItem>
<AppNavigationItem
key="menu-archived"
:to="{ name: routes.ARCHIVED }"
@ -33,12 +42,12 @@
</AppNavigationCounter>
</AppNavigationItem>
<AppNavigationItem
key="menu-shared-folders"
:to="{ name: routes.SHARED_FOLDERS }"
:title="t('bookmarks', 'Shared with you')">
<ShareVariantIcon slot="icon" :size="18" :fill-color="colorMainText" />
<AppNavigationCounter slot="counter">
{{ sharedFoldersCount }}
key="menu-duplicated"
:to="{ name: routes.DUPLICATED }"
:title="t('bookmarks', 'Duplicates')">
<VectorLinkIcon slot="icon" :size="18" :fill-color="colorMainText" />
<AppNavigationCounter v-show="Boolean(duplicatedBookmarksCount)" slot="counter">
{{ duplicatedBookmarksCount }}
</AppNavigationCounter>
</AppNavigationItem>
<AppNavigationItem
@ -46,7 +55,7 @@
:to="{ name: routes.UNAVAILABLE }"
:title="t('bookmarks', 'Broken links')">
<LinkVariantOffIcon slot="icon" :size="18" :fill-color="colorMainText" />
<AppNavigationCounter slot="counter">
<AppNavigationCounter v-show="Boolean(unavailableBookmarksCount)" slot="counter">
{{ unavailableBookmarksCount }}
</AppNavigationCounter>
</AppNavigationItem>
@ -117,6 +126,7 @@ import ShareVariantIcon from 'vue-material-design-icons/ShareVariant'
import FileDocumentMultipleIcon from 'vue-material-design-icons/FileDocumentMultiple'
import TagPlusIcon from 'vue-material-design-icons/TagPlus'
import TagMultipleIcon from 'vue-material-design-icons/TagMultiple'
import VectorLinkIcon from 'vue-material-design-icons/VectorLink'
import ProgressBar from 'vue-simple-progress'
import Settings from './Settings'
import { actions, mutations } from '../store/'
@ -140,6 +150,7 @@ export default {
TagMultipleIcon,
FileDocumentMultipleIcon,
ShareVariantIcon,
VectorLinkIcon,
},
data() {
return {}
@ -164,6 +175,9 @@ export default {
archivedBookmarksCount() {
return this.$store.state.archivedCount
},
duplicatedBookmarksCount() {
return this.$store.state.duplicatedCount
},
bookmarksLimit() {
return this.$store.state.settings.limit
},

View File

@ -115,6 +115,9 @@ export default {
case privateRoutes.ARCHIVED:
this.$store.dispatch(actions.FILTER_BY_ARCHIVED)
break
case privateRoutes.DUPLICATED:
this.$store.dispatch(actions.FILTER_BY_DUPLICATED)
break
case privateRoutes.SHARED_FOLDERS:
this.$store.commit(mutations.REMOVE_ALL_BOOKMARKS)
await this.$store.dispatch(actions.LOAD_SHARED_FOLDERS)
@ -159,6 +162,7 @@ export default {
this.$store.dispatch(actions.COUNT_BOOKMARKS, -1),
this.$store.dispatch(actions.COUNT_UNAVAILABLE),
this.$store.dispatch(actions.COUNT_ARCHIVED),
this.$store.dispatch(actions.COUNT_DUPLICATED),
])
},

View File

@ -26,6 +26,7 @@ export const privateRoutes = {
ARCHIVED: 'ARCHIVED',
BOOKMARKLET: 'bookmarklet',
SHARED_FOLDERS: 'SHARED_FOLDERS',
DUPLICATED: 'DUPLICATED',
}
export const publicRoutes = {
@ -93,6 +94,11 @@ export default new Router({
name: privateRoutes.SHARED_FOLDERS,
component: ViewPrivate,
},
{
path: '/duplicated',
name: privateRoutes.DUPLICATED,
component: ViewPrivate,
},
{
path: '/bookmarklet',
name: privateRoutes.BOOKMARKLET,

View File

@ -20,6 +20,8 @@ export const actions = {
COUNT_BOOKMARKS: 'COUNT_BOOKMARKS',
COUNT_UNAVAILABLE: 'COUNT_UNAVAILABLE',
COUNT_ARCHIVED: 'COUNT_ARCHIVED',
COUNT_DUPLICATED: 'COUNT_DUPLICATED',
CREATE_BOOKMARK: 'CREATE_BOOKMARK',
FIND_BOOKMARK: 'FIND_BOOKMARK',
LOAD_BOOKMARK: 'LOAD_BOOKMARK',
@ -57,6 +59,7 @@ export const actions = {
FILTER_BY_UNTAGGED: 'FILTER_BY_UNTAGGED',
FILTER_BY_UNAVAILABLE: 'FILTER_BY_UNAVAILABLE',
FILTER_BY_ARCHIVED: 'FILTER_BY_ARCHIVED',
FILTER_BY_DUPLICATED: 'FILTER_BY_DUPLICATED',
FILTER_BY_TAGS: 'FILTER_BY_TAGS',
FILTER_BY_FOLDER: 'FILTER_BY_FOLDER',
FILTER_BY_SHARED_FOLDERS: 'FILTER_BY_SHARED_FOLDERS',
@ -133,6 +136,28 @@ export default {
throw err
}
},
async [actions.COUNT_DUPLICATED]({ commit, dispatch, state }) {
try {
const response = await axios.get(url(state, '/bookmark/duplicated'))
const {
data: { item: count, data, status },
} = response
if (status !== 'success') {
throw new Error(data)
}
commit(mutations.SET_DUPLICATED_COUNT, count)
} catch (err) {
console.error(err)
commit(
mutations.SET_ERROR,
AppGlobal.methods.t(
'bookmarks',
'Failed to count duplicated bookmarks'
)
)
throw err
}
},
async [actions.COUNT_BOOKMARKS]({ commit, dispatch, state }, folderId) {
try {
const response = await axios.get(
@ -959,6 +984,10 @@ export default {
commit(mutations.SET_QUERY, { archived: true })
return dispatch(actions.FETCH_PAGE)
},
[actions.FILTER_BY_DUPLICATED]({ dispatch, commit }) {
commit(mutations.SET_QUERY, { duplicated: true })
return dispatch(actions.FETCH_PAGE)
},
[actions.FILTER_BY_FOLDER]({ dispatch, commit, state }, folder) {
commit(mutations.SET_QUERY, { folder })
if (state.settings.sorting === 'index') {

View File

@ -59,6 +59,7 @@ export default {
countsByFolder: {},
unavailableCount: 0,
archivedCount: 0,
duplicatedCount: 0,
selection: {
folders: [],
bookmarks: [],

View File

@ -26,6 +26,7 @@ export const mutations = {
SET_BOOKMARK_COUNT: 'SET_BOOKMARK_COUNT',
SET_UNAVAILABLE_COUNT: 'SET_UNAVAILABLE_COUNT',
SET_ARCHIVED_COUNT: 'SET_ARCHIVED_COUNT',
SET_DUPLICATED_COUNT: 'SET_DUPLICATED_COUNT',
ADD_TAG: 'ADD_TAG',
SET_TAGS: 'SET_TAGS',
RENAME_TAG: 'RENAME_TAG',
@ -202,6 +203,9 @@ export default {
[mutations.SET_ARCHIVED_COUNT](state, count) {
state.archivedCount = count
},
[mutations.SET_DUPLICATED_COUNT](state, count) {
state.duplicatedCount = count
},
[mutations.SET_SIDEBAR](state, sidebar) {
state.sidebar = sidebar