Add method to exclude or include some tags

This commit is contained in:
Radhi Fadlillah 2019-08-11 15:55:31 +07:00
parent 11c6680397
commit 4e76288e09
8 changed files with 311 additions and 66 deletions

View file

@ -25,6 +25,7 @@ func printCmd() *cobra.Command {
cmd.Flags().BoolP("index-only", "i", false, "Only print the index of bookmarks")
cmd.Flags().StringP("search", "s", "", "Search bookmark with specified keyword")
cmd.Flags().StringSliceP("tags", "t", []string{}, "Print bookmarks with matching tag(s)")
cmd.Flags().StringSliceP("exclude-tags", "e", []string{}, "Print bookmarks without these tag(s)")
return cmd
}
@ -36,6 +37,7 @@ func printHandler(cmd *cobra.Command, args []string) {
useJSON, _ := cmd.Flags().GetBool("json")
indexOnly, _ := cmd.Flags().GetBool("index-only")
orderLatest, _ := cmd.Flags().GetBool("latest")
excludedTags, _ := cmd.Flags().GetStringSlice("exclude-tags")
// Convert args to ids
ids, err := parseStrIndices(args)
@ -51,10 +53,11 @@ func printHandler(cmd *cobra.Command, args []string) {
}
searchOptions := database.GetBookmarksOptions{
IDs: ids,
Tags: tags,
Keyword: keyword,
OrderMethod: orderMethod,
IDs: ids,
Tags: tags,
ExcludedTags: excludedTags,
Keyword: keyword,
OrderMethod: orderMethod,
}
bookmarks, err := db.GetBookmarks(searchOptions)

View file

@ -20,13 +20,14 @@ const (
// GetBookmarksOptions is options for fetching bookmarks from database.
type GetBookmarksOptions struct {
IDs []int
Tags []string
Keyword string
WithContent bool
OrderMethod OrderMethod
Limit int
Offset int
IDs []int
Tags []string
ExcludedTags []string
Keyword string
WithContent bool
OrderMethod OrderMethod
Limit int
Offset int
}
// DB is interface for accessing and manipulating data in database.

View file

@ -228,28 +228,72 @@ func (db *MySQLDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookmar
// Add where clause
args := []interface{}{}
// Add where clause for IDs
if len(opts.IDs) > 0 {
query += ` AND id IN (?)`
args = append(args, opts.IDs)
}
// Add where clause for search keyword
if opts.Keyword != "" {
query += ` AND (
url LIKE ? OR
MATCH(title, excerpt, content) AGAINST (? IN BOOLEAN MODE)
)`
args = append(args,
"%"+opts.Keyword+"%",
opts.Keyword)
args = append(args, "%"+opts.Keyword+"%", opts.Keyword)
}
// Add where clause for tags.
// First we check for * in excluded and included tags,
// which means all tags will be excluded and included, respectively.
excludeAllTags := false
for _, excludedTag := range opts.ExcludedTags {
if excludedTag == "*" {
excludeAllTags = true
opts.ExcludedTags = []string{}
break
}
}
includeAllTags := false
for _, includedTag := range opts.Tags {
if includedTag == "*" {
includeAllTags = true
opts.Tags = []string{}
break
}
}
// If all tags excluded, we will only show bookmark without tags.
// In other hand, if all tags included, we will only show bookmark with tags.
if excludeAllTags {
query += ` AND id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`
} else if includeAllTags {
query += ` AND id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`
}
// Now we only need to find the normal tags
if len(opts.Tags) > 0 {
query += ` AND id IN (
SELECT bookmark_id FROM bookmark_tag
WHERE tag_id IN (SELECT id FROM tag WHERE name IN (?)))`
SELECT bt.bookmark_id
FROM bookmark_tag bt
LEFT JOIN tag t ON bt.tag_id = t.id
WHERE t.name IN(?)
GROUP BY bt.bookmark_id
HAVING COUNT(bt.bookmark_id) = ?)`
args = append(args, opts.Tags)
args = append(args, opts.Tags, len(opts.Tags))
}
if len(opts.ExcludedTags) > 0 {
query += ` AND id NOT IN (
SELECT DISTINCT bt.bookmark_id
FROM bookmark_tag bt
LEFT JOIN tag t ON bt.tag_id = t.id
WHERE t.name IN(?))`
args = append(args, opts.ExcludedTags)
}
// Add order clause
@ -312,11 +356,13 @@ func (db *MySQLDatabase) GetBookmarksCount(opts GetBookmarksOptions) (int, error
// Add where clause
args := []interface{}{}
// Add where clause for IDs
if len(opts.IDs) > 0 {
query += ` AND id IN (?)`
args = append(args, opts.IDs)
}
// Add where clause for search keyword
if opts.Keyword != "" {
query += ` AND (
url LIKE ? OR
@ -328,12 +374,56 @@ func (db *MySQLDatabase) GetBookmarksCount(opts GetBookmarksOptions) (int, error
opts.Keyword)
}
// Add where clause for tags.
// First we check for * in excluded and included tags,
// which means all tags will be excluded and included, respectively.
excludeAllTags := false
for _, excludedTag := range opts.ExcludedTags {
if excludedTag == "*" {
excludeAllTags = true
opts.ExcludedTags = []string{}
break
}
}
includeAllTags := false
for _, includedTag := range opts.Tags {
if includedTag == "*" {
includeAllTags = true
opts.Tags = []string{}
break
}
}
// If all tags excluded, we will only show bookmark without tags.
// In other hand, if all tags included, we will only show bookmark with tags.
if excludeAllTags {
query += ` AND id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`
} else if includeAllTags {
query += ` AND id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`
}
// Now we only need to find the normal tags
if len(opts.Tags) > 0 {
query += ` AND id IN (
SELECT bookmark_id FROM bookmark_tag
WHERE tag_id IN (SELECT id FROM tag WHERE name IN (?)))`
SELECT bt.bookmark_id
FROM bookmark_tag bt
LEFT JOIN tag t ON bt.tag_id = t.id
WHERE t.name IN(?)
GROUP BY bt.bookmark_id
HAVING COUNT(bt.bookmark_id) = ?)`
args = append(args, opts.Tags)
args = append(args, opts.Tags, len(opts.Tags))
}
if len(opts.ExcludedTags) > 0 {
query += ` AND id NOT IN (
SELECT DISTINCT bt.bookmark_id
FROM bookmark_tag bt
LEFT JOIN tag t ON bt.tag_id = t.id
WHERE t.name IN(?))`
args = append(args, opts.ExcludedTags)
}
// Expand query, because some of the args might be an array

View file

@ -224,11 +224,13 @@ func (db *SQLiteDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookma
// Add where clause
args := []interface{}{}
// Add where clause for IDs
if len(opts.IDs) > 0 {
query += ` AND b.id IN (?)`
args = append(args, opts.IDs)
}
// Add where clause for search keyword
if opts.Keyword != "" {
query += ` AND (b.url LIKE ? OR b.excerpt LIKE ? OR b.id IN (
SELECT docid id
@ -242,12 +244,56 @@ func (db *SQLiteDatabase) GetBookmarks(opts GetBookmarksOptions) ([]model.Bookma
opts.Keyword)
}
// Add where clause for tags.
// First we check for * in excluded and included tags,
// which means all tags will be excluded and included, respectively.
excludeAllTags := false
for _, excludedTag := range opts.ExcludedTags {
if excludedTag == "*" {
excludeAllTags = true
opts.ExcludedTags = []string{}
break
}
}
includeAllTags := false
for _, includedTag := range opts.Tags {
if includedTag == "*" {
includeAllTags = true
opts.Tags = []string{}
break
}
}
// If all tags excluded, we will only show bookmark without tags.
// In other hand, if all tags included, we will only show bookmark with tags.
if excludeAllTags {
query += ` AND b.id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`
} else if includeAllTags {
query += ` AND b.id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`
}
// Now we only need to find the normal tags
if len(opts.Tags) > 0 {
query += ` AND b.id IN (
SELECT bookmark_id FROM bookmark_tag
WHERE tag_id IN (SELECT id FROM tag WHERE name IN (?)))`
SELECT bt.bookmark_id
FROM bookmark_tag bt
LEFT JOIN tag t ON bt.tag_id = t.id
WHERE t.name IN(?)
GROUP BY bt.bookmark_id
HAVING COUNT(bt.bookmark_id) = ?)`
args = append(args, opts.Tags)
args = append(args, opts.Tags, len(opts.Tags))
}
if len(opts.ExcludedTags) > 0 {
query += ` AND b.id NOT IN (
SELECT DISTINCT bt.bookmark_id
FROM bookmark_tag bt
LEFT JOIN tag t ON bt.tag_id = t.id
WHERE t.name IN(?))`
args = append(args, opts.ExcludedTags)
}
// Add order clause
@ -313,11 +359,13 @@ func (db *SQLiteDatabase) GetBookmarksCount(opts GetBookmarksOptions) (int, erro
// Add where clause
args := []interface{}{}
// Add where clause for IDs
if len(opts.IDs) > 0 {
query += ` AND b.id IN (?)`
args = append(args, opts.IDs)
}
// Add where clause for search keyword
if opts.Keyword != "" {
query += ` AND (b.url LIKE ? OR b.excerpt LIKE ? OR b.id IN (
SELECT docid id
@ -331,12 +379,56 @@ func (db *SQLiteDatabase) GetBookmarksCount(opts GetBookmarksOptions) (int, erro
opts.Keyword)
}
// Add where clause for tags.
// First we check for * in excluded and included tags,
// which means all tags will be excluded and included, respectively.
excludeAllTags := false
for _, excludedTag := range opts.ExcludedTags {
if excludedTag == "*" {
excludeAllTags = true
opts.ExcludedTags = []string{}
break
}
}
includeAllTags := false
for _, includedTag := range opts.Tags {
if includedTag == "*" {
includeAllTags = true
opts.Tags = []string{}
break
}
}
// If all tags excluded, we will only show bookmark without tags.
// In other hand, if all tags included, we will only show bookmark with tags.
if excludeAllTags {
query += ` AND b.id NOT IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`
} else if includeAllTags {
query += ` AND b.id IN (SELECT DISTINCT bookmark_id FROM bookmark_tag)`
}
// Now we only need to find the normal tags
if len(opts.Tags) > 0 {
query += ` AND b.id IN (
SELECT bookmark_id FROM bookmark_tag
WHERE tag_id IN (SELECT id FROM tag WHERE name IN (?)))`
SELECT bt.bookmark_id
FROM bookmark_tag bt
LEFT JOIN tag t ON bt.tag_id = t.id
WHERE t.name IN(?)
GROUP BY bt.bookmark_id
HAVING COUNT(bt.bookmark_id) = ?)`
args = append(args, opts.Tags)
args = append(args, opts.Tags, len(opts.Tags))
}
if len(opts.ExcludedTags) > 0 {
query += ` AND b.id NOT IN (
SELECT DISTINCT bt.bookmark_id
FROM bookmark_tag bt
LEFT JOIN tag t ON bt.tag_id = t.id
WHERE t.name IN(?))`
args = append(args, opts.ExcludedTags)
}
// Expand query, because some of the args might be an array

View file

@ -11,7 +11,7 @@ var template = `
<p class="id" v-show="showId">{{id}}</p>
</a>
<div class="bookmark-tags" v-if="tags.length > 0">
<a v-for="tag in tags" @click="tagClicked(tag.name)">{{tag.name}}</a>
<a v-for="tag in tags" @click="tagClicked($event, tag.name)">{{tag.name}}</a>
</div>
<div class="spacer"></div>
<div class="bookmark-menu">
@ -78,8 +78,8 @@ export default {
}
},
methods: {
tagClicked(name) {
this.$emit("tag-clicked", name);
tagClicked(name, event) {
this.$emit("tag-clicked", name, event);
},
selectBookmark() {
this.$emit("select", this.eventItem);

View file

@ -8,7 +8,7 @@ var template = `
<a title="Add new bookmark" @click="showDialogAdd">
<i class="fas fa-fw fa-plus-circle"></i>
</a>
<a title="Show tags" @click="showDialogTags">
<a v-if="tags.length > 0" title="Show tags" @click="showDialogTags">
<i class="fas fa-fw fa-tags"></i>
</a>
<a title="Batch edit" @click="toggleEditMode">
@ -46,6 +46,7 @@ var template = `
:imageURL="book.imageURL"
:hasContent="book.hasContent"
:hasArchive="book.hasArchive"
:tags="book.tags"
:index="index"
:key="book.id"
:editMode="editMode"
@ -53,7 +54,7 @@ var template = `
:listMode="displayOptions.listMode"
:selected="isSelected(book.id)"
@select="toggleSelection"
@tag-clicked="filterTag"
@tag-clicked="bookmarkTagClicked"
@edit="showDialogEdit"
@delete="showDialogDelete"
@update="showDialogUpdateCache">
@ -68,8 +69,10 @@ var template = `
<p class="empty-message" v-if="!loading && listIsEmpty">No saved bookmarks yet :(</p>
<div class="loading-overlay" v-if="loading"><i class="fas fa-fw fa-spin fa-spinner"></i></div>
<custom-dialog id="dialog-tags" v-bind="dialogTags">
<a v-for="(tag, idx) in tags" @click="tagClicked(idx, tag)">
{{tag.name}}<span>{{tag.nBookmarks}}</span>
<a @click="filterTag('*')">(all tagged)</a>
<a @click="filterTag('*', true)">(all untagged)</a>
<a v-for="(tag, idx) in tags" @click="dialogTagClicked($event, idx, tag)">
#{{tag.name}}<span>{{tag.nBookmarks}}</span>
</a>
</custom-dialog>
<custom-dialog v-bind="dialog"/>
@ -156,33 +159,46 @@ export default {
fetchTags = (typeof fetchTags === "boolean") ? fetchTags : false;
// Parse search query
var rxTagA = /['"]#([^'"]+)['"]/g, // "#tag with space"
rxTagB = /(^|\s+)#(\S+)/g, // #tag-without-space
keyword = this.search,
var keyword = this.search,
rxExcludeTagA = /(^|\s)-tag:["']([^"']+)["']/i, // -tag:"with space"
rxExcludeTagB = /(^|\s)-tag:(\S+)/i, // -tag:without-space
rxIncludeTagA = /(^|\s)tag:["']([^"']+)["']/i, // tag:"with space"
rxIncludeTagB = /(^|\s)tag:(\S+)/i, // tag:without-space
tags = [],
excludedTags = [],
rxResult;
// Fetch tag A first
while (rxResult = rxTagA.exec(keyword)) {
tags.push(rxResult[1]);
// Get excluded tag first, while also removing it from keyword
while (rxResult = rxExcludeTagA.exec(keyword)) {
keyword = keyword.replace(rxResult[0], "");
excludedTags.push(rxResult[2]);
}
// Clear tag A from keyword
keyword = keyword.replace(rxTagA, "");
while (rxResult = rxExcludeTagB.exec(keyword)) {
keyword = keyword.replace(rxResult[0], "");
excludedTags.push(rxResult[2]);
}
// Fetch tag B
while (rxResult = rxTagB.exec(keyword)) {
// Get included tags
while (rxResult = rxIncludeTagA.exec(keyword)) {
keyword = keyword.replace(rxResult[0], "");
tags.push(rxResult[2]);
}
// Clear tag B from keyword, then trim keyword
keyword = keyword.replace(rxTagB, "").trim().replace(/\s+/g, " ");
while (rxResult = rxIncludeTagB.exec(keyword)) {
keyword = keyword.replace(rxResult[0], "");
tags.push(rxResult[2]);
}
// Trim keyword
keyword = keyword.trim().replace(/\s+/g, " ");
// Prepare URL for API
var url = new URL("/api/bookmarks", document.URL);
url.search = new URLSearchParams({
keyword: keyword,
tags: tags.join(","),
exclude: excludedTags.join(","),
page: this.page
});
@ -268,23 +284,59 @@ export default {
isSelected(bookId) {
return this.selection.findIndex(el => el.id === bookId) > -1;
},
tagClicked(idx, tag) {
dialogTagClicked(event, idx, tag) {
if (!this.dialogTags.editMode) {
this.filterTag(tag.name);
this.filterTag(tag.name, event.altKey);
} else {
this.dialogTags.visible = false;
this.showDialogRenameTag(idx, tag);
}
},
filterTag(tagName) {
var rxSpace = /\s+/g,
newTag = rxSpace.test(tagName) ? `"#${tagName}"` : `#${tagName}`;
bookmarkTagClicked(event, tagName) {
this.filterTag(tagName, event.altKey);
},
filterTag(tagName, excludeMode) {
// Set default parameter
excludeMode = (typeof excludeMode === "boolean") ? excludeMode : false;
if (!this.search.includes(newTag)) {
this.search += ` ${newTag}`;
this.search = this.search.trim();
if (tagName === "*") {
this.search = excludeMode ? "-tag:*" : "tag:*";
this.loadData();
return;
}
var rxSpace = /\s+/g,
includeTag = rxSpace.test(tagName) ? `tag:"${tagName}"` : `tag:${tagName}`,
excludeTag = "-" + includeTag,
rxIncludeTag = new RegExp(`(^|\\s)${includeTag}`, "ig"),
rxExcludeTag = new RegExp(`(^|\\s)${excludeTag}`, "ig"),
search = this.search;
if (excludeMode) {
if (rxExcludeTag.test(search)) {
return;
}
if (rxIncludeTag.test(search)) {
this.search = search.replace(rxIncludeTag, "$1" + excludeTag);
} else {
search += ` ${excludeTag}`;
this.search = search.trim();
}
} else {
if (rxIncludeTag.test(search)) {
return;
}
if (rxExcludeTag.test(search)) {
this.search = search.replace(rxExcludeTag, "$1" + includeTag);
} else {
search += ` ${includeTag}`;
this.search = search.trim();
}
}
this.loadData();
},
showDialogAdd() {
this.showDialog({

File diff suppressed because one or more lines are too long

View file

@ -121,14 +121,20 @@ func (h *handler) apiGetBookmarks(w http.ResponseWriter, r *http.Request, ps htt
// Get URL queries
keyword := r.URL.Query().Get("keyword")
strTags := r.URL.Query().Get("tags")
strPage := r.URL.Query().Get("page")
strTags := r.URL.Query().Get("tags")
strExcludedTags := r.URL.Query().Get("exclude")
tags := strings.Split(strTags, ",")
if len(tags) == 1 && tags[0] == "" {
tags = []string{}
}
excludedTags := strings.Split(strExcludedTags, ",")
if len(excludedTags) == 1 && excludedTags[0] == "" {
excludedTags = []string{}
}
page, _ := strconv.Atoi(strPage)
if page < 1 {
page = 1
@ -136,11 +142,12 @@ func (h *handler) apiGetBookmarks(w http.ResponseWriter, r *http.Request, ps htt
// Prepare filter for database
searchOptions := database.GetBookmarksOptions{
Tags: tags,
Keyword: keyword,
Limit: 30,
Offset: (page - 1) * 30,
OrderMethod: database.ByLastAdded,
Tags: tags,
ExcludedTags: excludedTags,
Keyword: keyword,
Limit: 30,
Offset: (page - 1) * 30,
OrderMethod: database.ByLastAdded,
}
// Calculate max page