mirror of https://github.com/nextcloud/bookmarks
Feature: Show and count duplicates
Signed-off-by: Marcel Klehr <mklehr@gmx.net>
This commit is contained in:
parent
3903176ed5
commit
cea9fe8477
|
@ -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'],
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
])
|
||||
},
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -59,6 +59,7 @@ export default {
|
|||
countsByFolder: {},
|
||||
unavailableCount: 0,
|
||||
archivedCount: 0,
|
||||
duplicatedCount: 0,
|
||||
selection: {
|
||||
folders: [],
|
||||
bookmarks: [],
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue