Add new search engine that supports logical expressions (and, or, not) (#1198)

* parser implementation

* add support for quoted strings

* add support for tags

* ignore empty tags

* implicit and

* prepare query conversion by disabling tests

* convert query logic

* fix nested combined tag searches

* simplify query logic

* Add special keyword support to parser

* Add special keyword support to query builder

* Handle invalid queries in query builder

* Notify user about invalid queries

* Add helper to strip tags from search query

* Make tag cloud show all tags from search query

* Use new method for extracting tags

* Add query for getting tags from search query

* Get selected tags through specific context

* Properly remove selected tags from complex queries

* cleanup

* Clarify bundle search terms

* Add documentation draft

* Improve adding tags to search query

* Add option to switch back to the old search
This commit is contained in:
Sascha Ißbrücker
2025-10-05 12:51:08 +02:00
committed by GitHub
parent 229d3b511f
commit 051bd39256
22 changed files with 2831 additions and 127 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-10-05 09:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0048_userprofile_default_mark_shared"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="legacy_search",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.5 on 2025-10-05 10:01
from django.contrib.auth import get_user_model
from django.db import migrations
from bookmarks.models import Toast
User = get_user_model()
def forwards(apps, schema_editor):
for user in User.objects.all():
toast = Toast(
key="new_search_toast",
message="This version replaces the search engine with a new implementation that supports logical operators (and, or, not). If you run into any issues with the new search, you can switch back to the old one by enabling legacy search in the settings.",
owner=user,
)
toast.save()
def reverse(apps, schema_editor):
Toast.objects.filter(key="new_search_toast").delete()
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0049_userprofile_legacy_search"),
]
operations = [
migrations.RunPython(forwards, reverse),
]

View File

@@ -2,7 +2,6 @@ import binascii
import hashlib
import logging
import os
from functools import cached_property
from typing import List
from django import forms
@@ -486,6 +485,7 @@ class UserProfile(models.Model):
sticky_pagination = models.BooleanField(default=False, null=False)
collapse_side_panel = models.BooleanField(default=False, null=False)
hide_bundles = models.BooleanField(default=False, null=False)
legacy_search = models.BooleanField(default=False, null=False)
def save(self, *args, **kwargs):
if self.custom_css:
@@ -528,6 +528,7 @@ class UserProfileForm(forms.ModelForm):
"sticky_pagination",
"collapse_side_panel",
"hide_bundles",
"legacy_search",
]

View File

@@ -15,6 +15,18 @@ from bookmarks.models import (
UserProfile,
parse_tag_string,
)
from bookmarks.services.search_query_parser import (
parse_search_query,
SearchExpression,
TermExpression,
TagExpression,
SpecialKeywordExpression,
AndExpression,
OrExpression,
NotExpression,
SearchQueryParseError,
extract_tag_names_from_query,
)
from bookmarks.utils import unique
@@ -45,6 +57,122 @@ def query_shared_bookmarks(
return _base_bookmarks_query(user, profile, search).filter(conditions)
def _convert_ast_to_q_object(ast_node: SearchExpression, profile: UserProfile) -> Q:
if isinstance(ast_node, TermExpression):
# Search across title, description, notes, URL
conditions = (
Q(title__icontains=ast_node.term)
| Q(description__icontains=ast_node.term)
| Q(notes__icontains=ast_node.term)
| Q(url__icontains=ast_node.term)
)
# In lax mode, also search in tag names
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
conditions = conditions | Exists(
Bookmark.objects.filter(
id=OuterRef("id"), tags__name__iexact=ast_node.term
)
)
return conditions
elif isinstance(ast_node, TagExpression):
# Use Exists() to avoid reusing the same join when combining multiple tag expressions with and
return Q(
Exists(
Bookmark.objects.filter(
id=OuterRef("id"), tags__name__iexact=ast_node.tag
)
)
)
elif isinstance(ast_node, SpecialKeywordExpression):
# Handle special keywords
if ast_node.keyword.lower() == "unread":
return Q(unread=True)
elif ast_node.keyword.lower() == "untagged":
return Q(tags=None)
else:
# Unknown keyword, return empty Q object (matches all)
return Q()
elif isinstance(ast_node, AndExpression):
# Combine left and right with AND
left_q = _convert_ast_to_q_object(ast_node.left, profile)
right_q = _convert_ast_to_q_object(ast_node.right, profile)
return left_q & right_q
elif isinstance(ast_node, OrExpression):
# Combine left and right with OR
left_q = _convert_ast_to_q_object(ast_node.left, profile)
right_q = _convert_ast_to_q_object(ast_node.right, profile)
return left_q | right_q
elif isinstance(ast_node, NotExpression):
# Negate the operand
operand_q = _convert_ast_to_q_object(ast_node.operand, profile)
return ~operand_q
else:
# Fallback for unknown node types
return Q()
def _filter_search_query(
query_set: QuerySet, query_string: str, profile: UserProfile
) -> QuerySet:
"""New search filtering logic using logical expressions."""
try:
ast = parse_search_query(query_string)
if ast:
search_query = _convert_ast_to_q_object(ast, profile)
query_set = query_set.filter(search_query)
except SearchQueryParseError:
# If the query cannot be parsed, return zero results
return query_set.none()
return query_set
def _filter_search_query_legacy(
query_set: QuerySet, query_string: str, profile: UserProfile
) -> QuerySet:
"""Legacy search filtering logic where everything is just combined with AND."""
# Split query into search terms and tags
query = parse_query_string(query_string)
# Filter for search terms and tags
for term in query["search_terms"]:
conditions = (
Q(title__icontains=term)
| Q(description__icontains=term)
| Q(notes__icontains=term)
| Q(url__icontains=term)
)
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
conditions = conditions | Exists(
Bookmark.objects.filter(id=OuterRef("id"), tags__name__iexact=term)
)
query_set = query_set.filter(conditions)
for tag_name in query["tag_names"]:
query_set = query_set.filter(tags__name__iexact=tag_name)
# Untagged bookmarks
if query["untagged"]:
query_set = query_set.filter(tags=None)
# Legacy unread bookmarks filter from query
if query["unread"]:
query_set = query_set.filter(unread=True)
return query_set
def _filter_bundle(query_set: QuerySet, bundle: BookmarkBundle) -> QuerySet:
# Search terms
search_terms = parse_query_string(bundle.search)["search_terms"]
@@ -113,34 +241,11 @@ def _base_bookmarks_query(
# If the date format is invalid, ignore the filter
pass
# Split query into search terms and tags
query = parse_query_string(search.q)
# Filter for search terms and tags
for term in query["search_terms"]:
conditions = (
Q(title__icontains=term)
| Q(description__icontains=term)
| Q(notes__icontains=term)
| Q(url__icontains=term)
)
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
conditions = conditions | Exists(
Bookmark.objects.filter(id=OuterRef("id"), tags__name__iexact=term)
)
query_set = query_set.filter(conditions)
for tag_name in query["tag_names"]:
query_set = query_set.filter(tags__name__iexact=tag_name)
# Untagged bookmarks
if query["untagged"]:
query_set = query_set.filter(tags=None)
# Legacy unread bookmarks filter from query
if query["unread"]:
query_set = query_set.filter(unread=True)
# Filter by search query
if profile.legacy_search:
query_set = _filter_search_query_legacy(query_set, search.q, profile)
else:
query_set = _filter_search_query(query_set, search.q, profile)
# Unread filter from bookmark search
if search.unread == BookmarkSearch.FILTER_UNREAD_YES:
@@ -241,6 +346,45 @@ def get_user_tags(user: User):
return Tag.objects.filter(owner=user).all()
def get_tags_for_query(user: User, profile: UserProfile, query: str) -> QuerySet:
tag_names = extract_tag_names_from_query(query, profile)
if not tag_names:
return Tag.objects.none()
tag_conditions = Q()
for tag_name in tag_names:
tag_conditions |= Q(name__iexact=tag_name)
return Tag.objects.filter(owner=user).filter(tag_conditions).distinct()
def get_shared_tags_for_query(
user: Optional[User], profile: UserProfile, query: str, public_only: bool
) -> QuerySet:
tag_names = extract_tag_names_from_query(query, profile)
if not tag_names:
return Tag.objects.none()
# Build conditions similar to query_shared_bookmarks
conditions = Q(bookmark__shared=True) & Q(
bookmark__owner__profile__enable_sharing=True
)
if public_only:
conditions = conditions & Q(
bookmark__owner__profile__enable_public_sharing=True
)
if user is not None:
conditions = conditions & Q(bookmark__owner=user)
tag_conditions = Q()
for tag_name in tag_names:
tag_conditions |= Q(name__iexact=tag_name)
return Tag.objects.filter(conditions).filter(tag_conditions).distinct()
def parse_query_string(query_string):
# Sanitize query params
if not query_string:

View File

@@ -0,0 +1,575 @@
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional
from bookmarks.models import UserProfile
class TokenType(Enum):
TERM = "TERM"
TAG = "TAG"
SPECIAL_KEYWORD = "SPECIAL_KEYWORD"
AND = "AND"
OR = "OR"
NOT = "NOT"
LPAREN = "LPAREN"
RPAREN = "RPAREN"
EOF = "EOF"
@dataclass
class Token:
type: TokenType
value: str
position: int
class SearchQueryTokenizer:
def __init__(self, query: str):
self.query = query.strip()
self.position = 0
self.current_char = self.query[0] if self.query else None
def advance(self):
"""Move to the next character in the query."""
self.position += 1
if self.position >= len(self.query):
self.current_char = None
else:
self.current_char = self.query[self.position]
def skip_whitespace(self):
"""Skip whitespace characters."""
while self.current_char and self.current_char.isspace():
self.advance()
def read_term(self) -> str:
"""Read a search term (sequence of non-whitespace, non-special characters)."""
term = ""
while (
self.current_char
and not self.current_char.isspace()
and self.current_char not in "()\"'#!"
):
term += self.current_char
self.advance()
return term
def read_quoted_string(self, quote_char: str) -> str:
"""Read a quoted string, handling escaped quotes."""
content = ""
self.advance() # skip opening quote
while self.current_char and self.current_char != quote_char:
if self.current_char == "\\":
# Handle escaped characters
self.advance()
if self.current_char:
if self.current_char == "n":
content += "\n"
elif self.current_char == "t":
content += "\t"
elif self.current_char == "r":
content += "\r"
elif self.current_char == "\\":
content += "\\"
elif self.current_char == quote_char:
content += quote_char
else:
# For any other escaped character, just include it as-is
content += self.current_char
self.advance()
else:
content += self.current_char
self.advance()
if self.current_char == quote_char:
self.advance() # skip closing quote
else:
# Unclosed quote - we could raise an error here, but let's be lenient
# and treat it as if the quote was closed at the end
pass
return content
def read_tag(self) -> str:
"""Read a tag (starts with # and continues until whitespace or special chars)."""
tag = ""
self.advance() # skip the # character
while (
self.current_char
and not self.current_char.isspace()
and self.current_char not in "()\"'"
):
tag += self.current_char
self.advance()
return tag
def read_special_keyword(self) -> str:
"""Read a special keyword (starts with ! and continues until whitespace or special chars)."""
keyword = ""
self.advance() # skip the ! character
while (
self.current_char
and not self.current_char.isspace()
and self.current_char not in "()\"'"
):
keyword += self.current_char
self.advance()
return keyword
def tokenize(self) -> List[Token]:
"""Convert the query string into a list of tokens."""
tokens = []
while self.current_char:
self.skip_whitespace()
if not self.current_char:
break
start_pos = self.position
if self.current_char == "(":
tokens.append(Token(TokenType.LPAREN, "(", start_pos))
self.advance()
elif self.current_char == ")":
tokens.append(Token(TokenType.RPAREN, ")", start_pos))
self.advance()
elif self.current_char in "\"'":
# Read a quoted string - always treated as a term
quote_char = self.current_char
term = self.read_quoted_string(quote_char)
tokens.append(Token(TokenType.TERM, term, start_pos))
elif self.current_char == "#":
# Read a tag
tag = self.read_tag()
# Only add the tag token if it has content
if tag:
tokens.append(Token(TokenType.TAG, tag, start_pos))
elif self.current_char == "!":
# Read a special keyword
keyword = self.read_special_keyword()
# Only add the keyword token if it has content
if keyword:
tokens.append(Token(TokenType.SPECIAL_KEYWORD, keyword, start_pos))
else:
# Read a term and check if it's an operator
term = self.read_term()
term_lower = term.lower()
if term_lower == "and":
tokens.append(Token(TokenType.AND, term, start_pos))
elif term_lower == "or":
tokens.append(Token(TokenType.OR, term, start_pos))
elif term_lower == "not":
tokens.append(Token(TokenType.NOT, term, start_pos))
else:
tokens.append(Token(TokenType.TERM, term, start_pos))
tokens.append(Token(TokenType.EOF, "", len(self.query)))
return tokens
class SearchExpression:
pass
@dataclass
class TermExpression(SearchExpression):
term: str
@dataclass
class TagExpression(SearchExpression):
tag: str
@dataclass
class SpecialKeywordExpression(SearchExpression):
keyword: str
@dataclass
class AndExpression(SearchExpression):
left: SearchExpression
right: SearchExpression
@dataclass
class OrExpression(SearchExpression):
left: SearchExpression
right: SearchExpression
@dataclass
class NotExpression(SearchExpression):
operand: SearchExpression
class SearchQueryParseError(Exception):
def __init__(self, message: str, position: int):
self.message = message
self.position = position
super().__init__(f"{message} at position {position}")
class SearchQueryParser:
def __init__(self, tokens: List[Token]):
self.tokens = tokens
self.position = 0
self.current_token = tokens[0] if tokens else Token(TokenType.EOF, "", 0)
def advance(self):
"""Move to the next token."""
if self.position < len(self.tokens) - 1:
self.position += 1
self.current_token = self.tokens[self.position]
def consume(self, expected_type: TokenType) -> Token:
"""Consume a token of the expected type or raise an error."""
if self.current_token.type == expected_type:
token = self.current_token
self.advance()
return token
else:
raise SearchQueryParseError(
f"Expected {expected_type.value}, got {self.current_token.type.value}",
self.current_token.position,
)
def parse(self) -> Optional[SearchExpression]:
"""Parse the tokens into an AST."""
if not self.tokens or (
len(self.tokens) == 1 and self.tokens[0].type == TokenType.EOF
):
return None
expr = self.parse_or_expression()
if self.current_token.type != TokenType.EOF:
raise SearchQueryParseError(
f"Unexpected token {self.current_token.type.value}",
self.current_token.position,
)
return expr
def parse_or_expression(self) -> SearchExpression:
"""Parse OR expressions (lowest precedence)."""
left = self.parse_and_expression()
while self.current_token.type == TokenType.OR:
self.advance() # consume OR
right = self.parse_and_expression()
left = OrExpression(left, right)
return left
def parse_and_expression(self) -> SearchExpression:
"""Parse AND expressions (medium precedence), including implicit AND."""
left = self.parse_not_expression()
while self.current_token.type == TokenType.AND or self.current_token.type in [
TokenType.TERM,
TokenType.TAG,
TokenType.SPECIAL_KEYWORD,
TokenType.LPAREN,
TokenType.NOT,
]:
if self.current_token.type == TokenType.AND:
self.advance() # consume explicit AND
# else: implicit AND (don't advance token)
right = self.parse_not_expression()
left = AndExpression(left, right)
return left
def parse_not_expression(self) -> SearchExpression:
"""Parse NOT expressions (high precedence)."""
if self.current_token.type == TokenType.NOT:
self.advance() # consume NOT
operand = self.parse_not_expression() # right associative
return NotExpression(operand)
return self.parse_primary_expression()
def parse_primary_expression(self) -> SearchExpression:
"""Parse primary expressions (terms, tags, special keywords, and parenthesized expressions)."""
if self.current_token.type == TokenType.TERM:
term = self.current_token.value
self.advance()
return TermExpression(term)
elif self.current_token.type == TokenType.TAG:
tag = self.current_token.value
self.advance()
return TagExpression(tag)
elif self.current_token.type == TokenType.SPECIAL_KEYWORD:
keyword = self.current_token.value
self.advance()
return SpecialKeywordExpression(keyword)
elif self.current_token.type == TokenType.LPAREN:
self.advance() # consume (
expr = self.parse_or_expression()
self.consume(TokenType.RPAREN) # consume )
return expr
else:
raise SearchQueryParseError(
f"Unexpected token {self.current_token.type.value}",
self.current_token.position,
)
def parse_search_query(query: str) -> Optional[SearchExpression]:
if not query or not query.strip():
return None
tokenizer = SearchQueryTokenizer(query)
tokens = tokenizer.tokenize()
parser = SearchQueryParser(tokens)
return parser.parse()
def _needs_parentheses(expr: SearchExpression, parent_type: type) -> bool:
if isinstance(expr, OrExpression) and parent_type == AndExpression:
return True
# AndExpression or OrExpression needs parentheses when inside NotExpression
if isinstance(expr, (AndExpression, OrExpression)) and parent_type == NotExpression:
return True
return False
def _is_simple_expression(expr: SearchExpression) -> bool:
"""Check if an expression is simple (term, tag, or keyword)."""
return isinstance(expr, (TermExpression, TagExpression, SpecialKeywordExpression))
def _expression_to_string(expr: SearchExpression, parent_type: type = None) -> str:
if isinstance(expr, TermExpression):
# Quote terms if they contain spaces or special characters
if " " in expr.term or any(c in expr.term for c in ["(", ")", '"', "'"]):
# Escape any quotes in the term
escaped = expr.term.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"'
return expr.term
elif isinstance(expr, TagExpression):
return f"#{expr.tag}"
elif isinstance(expr, SpecialKeywordExpression):
return f"!{expr.keyword}"
elif isinstance(expr, NotExpression):
# Don't pass parent type to children
operand_str = _expression_to_string(expr.operand, None)
# Add parentheses if the operand is a binary operation
if isinstance(expr.operand, (AndExpression, OrExpression)):
return f"not ({operand_str})"
return f"not {operand_str}"
elif isinstance(expr, AndExpression):
# Don't pass parent type to children - they'll add their own parens only if needed
left_str = _expression_to_string(expr.left, None)
right_str = _expression_to_string(expr.right, None)
# Add parentheses to children if needed for precedence
if _needs_parentheses(expr.left, AndExpression):
left_str = f"({left_str})"
if _needs_parentheses(expr.right, AndExpression):
right_str = f"({right_str})"
result = f"{left_str} {right_str}"
# Add outer parentheses if needed based on parent context
if parent_type and _needs_parentheses(expr, parent_type):
result = f"({result})"
return result
elif isinstance(expr, OrExpression):
# Don't pass parent type to children
left_str = _expression_to_string(expr.left, None)
right_str = _expression_to_string(expr.right, None)
# OrExpression children don't need parentheses unless they're also OR (handled by recursion)
result = f"{left_str} or {right_str}"
# Add outer parentheses if needed based on parent context
if parent_type and _needs_parentheses(expr, parent_type):
result = f"({result})"
return result
else:
raise ValueError(f"Unknown expression type: {type(expr)}")
def expression_to_string(expr: Optional[SearchExpression]) -> str:
if expr is None:
return ""
return _expression_to_string(expr)
def _strip_tag_from_expression(
expr: Optional[SearchExpression], tag_name: str, enable_lax_search: bool = False
) -> Optional[SearchExpression]:
if expr is None:
return None
if isinstance(expr, TagExpression):
# Remove this tag if it matches
if expr.tag.lower() == tag_name.lower():
return None
return expr
elif isinstance(expr, TermExpression):
# In lax search mode, also remove terms that match the tag name
if enable_lax_search and expr.term.lower() == tag_name.lower():
return None
return expr
elif isinstance(expr, SpecialKeywordExpression):
# Keep special keywords as-is
return expr
elif isinstance(expr, NotExpression):
# Recursively filter the operand
filtered_operand = _strip_tag_from_expression(
expr.operand, tag_name, enable_lax_search
)
if filtered_operand is None:
# If the operand is removed, the whole NOT expression should be removed
return None
return NotExpression(filtered_operand)
elif isinstance(expr, AndExpression):
# Recursively filter both sides
left = _strip_tag_from_expression(expr.left, tag_name, enable_lax_search)
right = _strip_tag_from_expression(expr.right, tag_name, enable_lax_search)
# If both sides are removed, remove the AND expression
if left is None and right is None:
return None
# If one side is removed, return the other side
elif left is None:
return right
elif right is None:
return left
else:
return AndExpression(left, right)
elif isinstance(expr, OrExpression):
# Recursively filter both sides
left = _strip_tag_from_expression(expr.left, tag_name, enable_lax_search)
right = _strip_tag_from_expression(expr.right, tag_name, enable_lax_search)
# If both sides are removed, remove the OR expression
if left is None and right is None:
return None
# If one side is removed, return the other side
elif left is None:
return right
elif right is None:
return left
else:
return OrExpression(left, right)
else:
# Unknown expression type, return as-is
return expr
def strip_tag_from_query(
query: str, tag_name: str, user_profile: UserProfile | None = None
) -> str:
try:
ast = parse_search_query(query)
except SearchQueryParseError:
return query
if ast is None:
return ""
# Determine if lax search is enabled
enable_lax_search = False
if user_profile is not None:
enable_lax_search = user_profile.tag_search == UserProfile.TAG_SEARCH_LAX
# Strip the tag from the AST
filtered_ast = _strip_tag_from_expression(ast, tag_name, enable_lax_search)
# Convert back to a query string
return expression_to_string(filtered_ast)
def _extract_tag_names_from_expression(
expr: Optional[SearchExpression], enable_lax_search: bool = False
) -> List[str]:
if expr is None:
return []
if isinstance(expr, TagExpression):
return [expr.tag]
elif isinstance(expr, TermExpression):
# In lax search mode, terms are also considered tags
if enable_lax_search:
return [expr.term]
return []
elif isinstance(expr, SpecialKeywordExpression):
# Special keywords are not tags
return []
elif isinstance(expr, NotExpression):
# Recursively extract from the operand
return _extract_tag_names_from_expression(expr.operand, enable_lax_search)
elif isinstance(expr, (AndExpression, OrExpression)):
# Recursively extract from both sides and combine
left_tags = _extract_tag_names_from_expression(expr.left, enable_lax_search)
right_tags = _extract_tag_names_from_expression(expr.right, enable_lax_search)
return left_tags + right_tags
else:
# Unknown expression type
return []
def extract_tag_names_from_query(
query: str, user_profile: UserProfile | None = None
) -> List[str]:
try:
ast = parse_search_query(query)
except SearchQueryParseError:
return []
if ast is None:
return []
# Determine if lax search is enabled
enable_lax_search = False
if user_profile is not None:
enable_lax_search = user_profile.tag_search == UserProfile.TAG_SEARCH_LAX
# Extract tag names from the AST
tag_names = _extract_tag_names_from_expression(ast, enable_lax_search)
# Deduplicate (case-insensitive) and sort
seen = set()
unique_tags = []
for tag in tag_names:
tag_lower = tag.lower()
if tag_lower not in seen:
seen.add(tag_lower)
unique_tags.append(tag_lower)
return sorted(unique_tags)

View File

@@ -4,7 +4,7 @@
border-radius: var(--border-radius);
color: var(--secondary-text-color);
text-align: center;
padding: var(--unit-16) var(--unit-8);
padding: var(--unit-8) var(--unit-8);
.empty-icon {
margin-bottom: var(--layout-spacing-lg);

View File

@@ -36,14 +36,14 @@
{% endif %}
{% if bookmark_list.description_display == 'inline' %}
<div class="description inline truncate">
{% if bookmark_item.tag_names %}
{% if bookmark_item.tags %}
<span class="tags">
{% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% for tag in bookmark_item.tags %}
<a href="?{{ tag.query_string }}">#{{ tag.name }}</a>
{% endfor %}
</span>
{% endif %}
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
{% if bookmark_item.tags and bookmark_item.description %} | {% endif %}
{% if bookmark_item.description %}
<span>{{ bookmark_item.description }}</span>
{% endif %}
@@ -52,10 +52,10 @@
{% if bookmark_item.description %}
<div class="description separate">{{ bookmark_item.description }}</div>
{% endif %}
{% if bookmark_item.tag_names %}
{% if bookmark_item.tags %}
<div class="tags">
{% for tag_name in bookmark_item.tag_names %}
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% for tag in bookmark_item.tags %}
<a href="?{{ tag.query_string }}">#{{ tag.name }}</a>
{% endfor %}
</div>
{% endif %}

View File

@@ -84,8 +84,8 @@
<section class="tags col-1">
<h3 id="details-modal-tags-title">Tags</h3>
<div>
{% for tag_name in details.bookmark.tag_names %}
<a href="{% url 'linkding:bookmarks.index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% for tag in details.tags %}
<a href="{% url 'linkding:bookmarks.index' %}?{{ tag.query_string }}">#{{ tag.name }}</a>
{% endfor %}
</div>
</section>

View File

@@ -1,4 +1,11 @@
<div class="empty">
{% if not bookmark_list.query_is_valid %}
<p class="empty-title h5">Invalid search query</p>
<p class="empty-subtitle">
The search query you entered is not valid. Common reasons are unclosed parentheses or a logical operator (AND, OR,
NOT) without operands. The error message from the parser is: "{{ bookmark_list.query_error_message }}".
</p>
{% else %}
<p class="empty-title h5">You have no bookmarks yet</p>
<p class="empty-subtitle">
You can get started by <a href="{% url 'linkding:bookmarks.new' %}">adding</a> bookmarks,
@@ -6,4 +13,5 @@
<a href="{% url 'linkding:settings.integrations' %}">browser extension</a> or the <a
href="{% url 'linkding:settings.integrations' %}">bookmarklet</a>.
</p>
{% endif %}
</div>

View File

@@ -4,7 +4,7 @@
{% if tag_cloud.has_selected_tags %}
<p class="selected-tags">
{% for tag in tag_cloud.selected_tags %}
<a href="?{% remove_tag_from_query tag.name %}"
<a href="?{{ tag.query_string }}"
class="text-bold mr-2">
<span>-{{ tag.name }}</span>
</a>
@@ -17,14 +17,14 @@
{% for tag in group.tags %}
{# Highlight first char of first tag in group #}
{% if forloop.counter == 1 %}
<a href="?{% add_tag_to_query tag.name %}"
<a href="?{{ tag.query_string }}"
class="mr-2" data-is-tag-item>
<span
class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
</a>
{% else %}
{# Render remaining tags normally #}
<a href="?{% add_tag_to_query tag.name %}"
<a href="?{{ tag.query_string }}"
class="mr-2" data-is-tag-item>
<span>{{ tag.name }}</span>
</a>

View File

@@ -11,7 +11,7 @@
</div>
<div class="form-group {% if form.search.errors %}has-error{% endif %}">
<label for="{{ form.search.id_for_label }}" class="form-label">Search</label>
<label for="{{ form.search.id_for_label }}" class="form-label">Search terms</label>
{{ form.search|add_class:"form-input"|attr:"autocomplete:off"|attr:"placeholder: " }}
{% if form.search.errors %}
<div class="form-input-hint">
@@ -19,7 +19,7 @@
</div>
{% endif %}
<div class="form-input-hint">
Search terms to match bookmarks in this bundle.
All of these search terms must be present in a bookmark to match.
</div>
</div>

View File

@@ -158,6 +158,18 @@
result will also include bookmarks where a search term matches otherwise.
</div>
</div>
<div class="form-group">
<label for="{{ form.legacy_search.id_for_label }}" class="form-checkbox">
{{ form.legacy_search }}
<i class="form-icon"></i> Enable legacy search
</label>
<div class="form-input-hint">
Since version 1.44.0, linkding has a new search engine that supports logical expressions (and, or, not).
If you run into any issues with the new search, you can enable this option to temporarily switch back to the old search.
Please report any issues you encounter with the new search on <a href="https://github.com/sissbruecker/linkding/issues" target="_blank">GitHub</a> so they can be addressed.
This option will be removed in a future version.
</div>
</div>
<div class="form-group">
<label for="{{ form.tag_grouping.id_for_label }}" class="form-label">Tag grouping</label>
{{ form.tag_grouping|add_class:"form-select width-25 width-sm-100" }}

View File

@@ -23,53 +23,6 @@ def update_query_string(context, **kwargs):
return query.urlencode()
@register.simple_tag(takes_context=True)
def add_tag_to_query(context, tag_name: str):
params = context.request.GET.copy()
# Append to or create query string
query_string = params.get("q", "")
query_string = (query_string + " #" + tag_name).strip()
params.setlist("q", [query_string])
# Remove details ID and page number
params.pop("details", None)
params.pop("page", None)
return params.urlencode()
@register.simple_tag(takes_context=True)
def remove_tag_from_query(context, tag_name: str):
params = context.request.GET.copy()
if params.__contains__("q"):
# Split query string into parts
query_string = params.__getitem__("q")
query_parts = query_string.split()
# Remove tag with hash
tag_name_with_hash = "#" + tag_name
query_parts = [
part
for part in query_parts
if str.lower(part) != str.lower(tag_name_with_hash)
]
# When using lax tag search, also remove tag without hash
profile = context.request.user_profile
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
query_parts = [
part for part in query_parts if str.lower(part) != str.lower(tag_name)
]
# Rebuild query string
query_string = " ".join(query_parts)
params.__setitem__("q", query_string)
# Remove details ID and page number
params.pop("details", None)
params.pop("page", None)
return params.urlencode()
@register.simple_tag(takes_context=True)
def replace_query_param(context, **kwargs):
query = context.request.GET.copy()
@@ -82,11 +35,6 @@ def replace_query_param(context, **kwargs):
return query.urlencode()
@register.filter(name="hash_tag")
def hash_tag(tag_name):
return "#" + tag_name
@register.filter(name="first_char")
def first_char(text):
return text[0]

View File

@@ -476,6 +476,27 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertEqual(tag_links[1].text, "#tag2")
self.assertEqual(tag_links[2].text, "#tag3")
def test_bookmark_tag_query_string(self):
# appends tag to existing query string
bookmark = self.setup_bookmark(title="term1 term2")
tag1 = self.setup_tag(name="tag1")
bookmark.tags.add(tag1)
html = self.render_template(url="/bookmarks?q=term1 and term2")
soup = self.make_soup(html)
tags = soup.select_one(".tags")
tag_links = tags.find_all("a")
self.assertEqual(len(tag_links), 1)
self.assertEqual(tag_links[0]["href"], "?q=term1+and+term2+%23tag1")
# wraps or expression in parentheses
html = self.render_template(url="/bookmarks?q=term1 or term2")
soup = self.make_soup(html)
tags = soup.select_one(".tags")
tag_links = tags.find_all("a")
self.assertEqual(len(tag_links), 1)
self.assertEqual(tag_links[0]["href"], "?q=%28term1+or+term2%29+%23tag1")
def test_should_render_web_archive_link_with_absolute_date_setting(self):
bookmark = self.setup_date_format_test(
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,
@@ -1017,6 +1038,34 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
'<p class="empty-title h5">You have no bookmarks yet</p>', html
)
def test_empty_state_with_valid_query_no_results(self):
self.setup_bookmark(title="Test Bookmark")
html = self.render_template(url="/bookmarks?q=nonexistent")
self.assertInHTML(
'<p class="empty-title h5">You have no bookmarks yet</p>', html
)
def test_empty_state_with_invalid_query(self):
self.setup_bookmark()
html = self.render_template(url="/bookmarks?q=(test")
self.assertInHTML('<p class="empty-title h5">Invalid search query</p>', html)
self.assertIn("Expected RPAREN", html)
def test_empty_state_with_legacy_search(self):
profile = self.get_or_create_test_user().profile
profile.legacy_search = True
profile.save()
self.setup_bookmark()
html = self.render_template(url="/bookmarks?q=(test")
# With legacy search, search queries are not validated
self.assertInHTML(
'<p class="empty-title h5">You have no bookmarks yet</p>', html
)
def test_pagination_is_not_sticky_by_default(self):
self.setup_bookmark()
html = self.render_template()

View File

@@ -192,10 +192,6 @@ class PaginationTagTest(TestCase, BookmarkFactoryMixin):
100, 10, 2, url="/test?details=1&page=2"
)
self.assertPrevLink(rendered_template, 1, href="/test?page=1")
self.assertPageLink(
rendered_template, 1, False, href="/test?page=1"
)
self.assertPageLink(
rendered_template, 2, True, href="/test?page=2"
)
self.assertPageLink(rendered_template, 1, False, href="/test?page=1")
self.assertPageLink(rendered_template, 2, True, href="/test?page=2")
self.assertNextLink(rendered_template, 3, href="/test?page=3")

View File

@@ -11,7 +11,7 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence
from bookmarks.utils import unique
class QueriesTestCase(TestCase, BookmarkFactoryMixin):
class QueriesBasicTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.profile = self.get_or_create_test_user().profile
@@ -1551,3 +1551,324 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
None, self.profile, BookmarkSearch(q="", bundle=bundle), False
)
self.assertQueryResult(query, [matching_bookmarks])
# Legacy search should be covered by basic test suite which was effectively the
# full test suite before advanced search was introduced.
class QueriesLegacySearchTestCase(QueriesBasicTestCase):
def setUp(self):
super().setUp()
self.profile.legacy_search = True
self.profile.save()
class QueriesAdvancedSearchTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.user = self.get_or_create_test_user()
self.profile = self.user.profile
self.python_bookmark = self.setup_bookmark(
title="Python Tutorial",
tags=[self.setup_tag(name="python"), self.setup_tag(name="tutorial")],
)
self.java_bookmark = self.setup_bookmark(
title="Java Guide",
tags=[self.setup_tag(name="java"), self.setup_tag(name="programming")],
)
self.deprecated_python_bookmark = self.setup_bookmark(
title="Old Python Guide",
tags=[self.setup_tag(name="python"), self.setup_tag(name="deprecated")],
)
self.javascript_tutorial = self.setup_bookmark(
title="JavaScript Basics",
tags=[self.setup_tag(name="javascript"), self.setup_tag(name="tutorial")],
)
self.web_development = self.setup_bookmark(
title="Web Development with React",
description="Modern web development",
tags=[self.setup_tag(name="react"), self.setup_tag(name="web")],
)
def test_explicit_and_operator(self):
search = BookmarkSearch(q="python AND tutorial")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [self.python_bookmark])
def test_or_operator(self):
search = BookmarkSearch(q="#python OR #java")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(
list(query),
[self.python_bookmark, self.java_bookmark, self.deprecated_python_bookmark],
)
def test_not_operator(self):
search = BookmarkSearch(q="#python AND NOT #deprecated")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [self.python_bookmark])
def test_implicit_and_between_terms(self):
search = BookmarkSearch(q="web development")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [self.web_development])
search = BookmarkSearch(q="python tutorial")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [self.python_bookmark])
def test_implicit_and_between_tags(self):
search = BookmarkSearch(q="#python #tutorial")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [self.python_bookmark])
def test_nested_and_expression(self):
search = BookmarkSearch(q="nonexistingterm OR (#python AND #tutorial)")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [self.python_bookmark])
search = BookmarkSearch(
q="(#javascript AND #tutorial) OR (#python AND #tutorial)"
)
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(
list(query), [self.javascript_tutorial, self.python_bookmark]
)
def test_mixed_terms_and_tags_with_operators(self):
# Set lax mode to allow term matching against tags
self.profile.tag_search = self.profile.TAG_SEARCH_LAX
self.profile.save()
search = BookmarkSearch(q="(tutorial OR guide) AND #python")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(
list(query), [self.python_bookmark, self.deprecated_python_bookmark]
)
def test_parentheses(self):
# Set lax mode to allow term matching against tags
self.profile.tag_search = self.profile.TAG_SEARCH_LAX
self.profile.save()
# Without parentheses
search = BookmarkSearch(q="python AND tutorial OR javascript AND tutorial")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(
list(query), [self.python_bookmark, self.javascript_tutorial]
)
# With parentheses
search = BookmarkSearch(q="(python OR javascript) AND tutorial")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(
list(query), [self.python_bookmark, self.javascript_tutorial]
)
def test_complex_query_with_all_operators(self):
# Set lax mode to allow term matching against tags
self.profile.tag_search = self.profile.TAG_SEARCH_LAX
self.profile.save()
search = BookmarkSearch(
q="(#python OR #javascript) AND tutorial AND NOT #deprecated"
)
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(
list(query), [self.python_bookmark, self.javascript_tutorial]
)
def test_quoted_strings_with_operators(self):
# Set lax mode to allow term matching against tags
self.profile.tag_search = self.profile.TAG_SEARCH_LAX
self.profile.save()
search = BookmarkSearch(q='"Web Development" OR tutorial')
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(
list(query),
[self.web_development, self.python_bookmark, self.javascript_tutorial],
)
def test_implicit_and_with_quoted_strings(self):
search = BookmarkSearch(q='"Web Development" react')
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [self.web_development])
def test_empty_query(self):
# empty query returns all bookmarks
search = BookmarkSearch(q="")
query = queries.query_bookmarks(self.user, self.profile, search)
expected = [
self.python_bookmark,
self.java_bookmark,
self.deprecated_python_bookmark,
self.javascript_tutorial,
self.web_development,
]
self.assertCountEqual(list(query), expected)
def test_unparseable_query_returns_no_results(self):
# Use a query that causes a parse error (unclosed parenthesis)
search = BookmarkSearch(q="(python AND tutorial")
query = queries.query_bookmarks(self.user, self.profile, search)
self.assertCountEqual(list(query), [])
class GetTagsForQueryTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.user = self.get_or_create_test_user()
self.profile = self.user.profile
def test_returns_tags_matching_query(self):
python_tag = self.setup_tag(name="python")
django_tag = self.setup_tag(name="django")
self.setup_tag(name="unused")
result = queries.get_tags_for_query(
self.user, self.profile, "#python and #django"
)
self.assertCountEqual(list(result), [python_tag, django_tag])
def test_case_insensitive_matching(self):
python_tag = self.setup_tag(name="Python")
result = queries.get_tags_for_query(self.user, self.profile, "#python")
self.assertCountEqual(list(result), [python_tag])
# having two tags with the same name returns both for now
other_python_tag = self.setup_tag(name="python")
result = queries.get_tags_for_query(self.user, self.profile, "#python")
self.assertCountEqual(list(result), [python_tag, other_python_tag])
def test_lax_mode_includes_terms(self):
python_tag = self.setup_tag(name="python")
django_tag = self.setup_tag(name="django")
self.profile.tag_search = UserProfile.TAG_SEARCH_LAX
self.profile.save()
result = queries.get_tags_for_query(
self.user, self.profile, "#python and django"
)
self.assertCountEqual(list(result), [python_tag, django_tag])
def test_strict_mode_excludes_terms(self):
python_tag = self.setup_tag(name="python")
self.setup_tag(name="django")
result = queries.get_tags_for_query(
self.user, self.profile, "#python and django"
)
self.assertCountEqual(list(result), [python_tag])
def test_only_returns_user_tags(self):
python_tag = self.setup_tag(name="python")
other_user = self.setup_user()
other_python = self.setup_tag(name="python", user=other_user)
other_django = self.setup_tag(name="django", user=other_user)
result = queries.get_tags_for_query(
self.user, self.profile, "#python and #django"
)
self.assertCountEqual(list(result), [python_tag])
self.assertNotIn(other_python, list(result))
self.assertNotIn(other_django, list(result))
def test_empty_query_returns_no_tags(self):
self.setup_tag(name="python")
result = queries.get_tags_for_query(self.user, self.profile, "")
self.assertCountEqual(list(result), [])
def test_query_with_no_tags_returns_empty(self):
self.setup_tag(name="python")
result = queries.get_tags_for_query(self.user, self.profile, "!unread")
self.assertCountEqual(list(result), [])
def test_nonexistent_tag_returns_empty(self):
self.setup_tag(name="python")
result = queries.get_tags_for_query(self.user, self.profile, "#ruby")
self.assertCountEqual(list(result), [])
class GetSharedTagsForQueryTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.user = self.get_or_create_test_user()
self.profile = self.user.profile
self.profile.enable_sharing = True
self.profile.save()
def test_returns_tags_from_shared_bookmarks(self):
python_tag = self.setup_tag(name="python")
self.setup_tag(name="django")
self.setup_bookmark(shared=True, tags=[python_tag])
result = queries.get_shared_tags_for_query(
None, self.profile, "#python and #django", public_only=False
)
self.assertCountEqual(list(result), [python_tag])
def test_excludes_tags_from_non_shared_bookmarks(self):
python_tag = self.setup_tag(name="python")
self.setup_tag(name="django")
self.setup_bookmark(shared=False, tags=[python_tag])
result = queries.get_shared_tags_for_query(
None, self.profile, "#python and #django", public_only=False
)
self.assertCountEqual(list(result), [])
def test_respects_sharing_enabled_setting(self):
self.profile.enable_sharing = False
self.profile.save()
python_tag = self.setup_tag(name="python")
self.setup_tag(name="django")
self.setup_bookmark(shared=True, tags=[python_tag])
result = queries.get_shared_tags_for_query(
None, self.profile, "#python and #django", public_only=False
)
self.assertCountEqual(list(result), [])
def test_public_only_flag(self):
# public sharing disabled
python_tag = self.setup_tag(name="python")
self.setup_tag(name="django")
self.setup_bookmark(shared=True, tags=[python_tag])
result = queries.get_shared_tags_for_query(
None, self.profile, "#python and #django", public_only=True
)
self.assertCountEqual(list(result), [])
# public sharing enabled
self.profile.enable_public_sharing = True
self.profile.save()
result = queries.get_shared_tags_for_query(
None, self.profile, "#python and #django", public_only=True
)
self.assertCountEqual(list(result), [python_tag])
def test_filters_by_user(self):
python_tag = self.setup_tag(name="python")
self.setup_tag(name="django")
self.setup_bookmark(shared=True, tags=[python_tag])
other_user = self.setup_user()
other_user.profile.enable_sharing = True
other_user.profile.save()
other_tag = self.setup_tag(name="python", user=other_user)
self.setup_bookmark(shared=True, tags=[other_tag], user=other_user)
result = queries.get_shared_tags_for_query(
self.user, self.profile, "#python and #django", public_only=False
)
self.assertCountEqual(list(result), [python_tag])
self.assertNotIn(other_tag, list(result))

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"sticky_pagination": False,
"collapse_side_panel": False,
"hide_bundles": False,
"legacy_search": False,
}
return {**form_data, **overrides}
@@ -122,6 +123,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"sticky_pagination": True,
"collapse_side_panel": True,
"hide_bundles": True,
"legacy_search": True,
}
response = self.client.post(
reverse("linkding:settings.update"), form_data, follow=True
@@ -206,6 +208,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
)
self.assertEqual(self.user.profile.hide_bundles, form_data["hide_bundles"])
self.assertEqual(self.user.profile.legacy_search, form_data["legacy_search"])
self.assertSuccessMessage(html, "Profile updated")

View File

@@ -234,6 +234,21 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
rendered_template,
)
def test_tag_url_wraps_or_expression_in_parenthesis(self):
tag = self.setup_tag(name="tag1")
self.setup_bookmark(tags=[tag], title="term1")
rendered_template = self.render_template(url="/test?q=term1 or term2")
self.assertInHTML(
"""
<a href="?q=%28term1+or+term2%29+%23tag1" class="mr-2" data-is-tag-item>
<span class="highlight-char">t</span><span>ag1</span>
</a>
""",
rendered_template,
)
def test_selected_tags(self):
tags = [
self.setup_tag(name="tag1"),
@@ -265,6 +280,63 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
rendered_template,
)
def test_selected_tags_complex_queries(self):
tags = [
self.setup_tag(name="tag1"),
self.setup_tag(name="tag2"),
]
self.setup_bookmark(tags=tags)
rendered_template = self.render_template(url="/test?q=%23tag1 or not %23tag2")
self.assertNumSelectedTags(rendered_template, 2)
self.assertInHTML(
"""
<a href="?q=not+%23tag2"
class="text-bold mr-2">
<span>-tag1</span>
</a>
""",
rendered_template,
)
self.assertInHTML(
"""
<a href="?q=%23tag1"
class="text-bold mr-2">
<span>-tag2</span>
</a>
""",
rendered_template,
)
rendered_template = self.render_template(
url="/test?q=%23tag1 and not (%23tag2 or term)"
)
self.assertNumSelectedTags(rendered_template, 2)
self.assertInHTML(
"""
<a href="?q=not+%28%23tag2+or+term%29"
class="text-bold mr-2">
<span>-tag1</span>
</a>
""",
rendered_template,
)
self.assertInHTML(
"""
<a href="?q=%23tag1+not+term"
class="text-bold mr-2">
<span>-tag2</span>
</a>
""",
rendered_template,
)
def test_selected_tags_with_lax_tag_search(self):
profile = self.get_or_create_test_user().profile
profile.tag_search = UserProfile.TAG_SEARCH_LAX
@@ -410,6 +482,12 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.assertTagGroups(rendered_template, [["tag3", "tag4", "tag5"]])
rendered_template = self.render_template(
url="/test?q=%23tag1 or (%23tag2 or not term)"
)
self.assertTagGroups(rendered_template, [["tag3", "tag4", "tag5"]])
def test_with_anonymous_user(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True

View File

@@ -19,6 +19,12 @@ from bookmarks.models import (
UserProfile,
Tag,
)
from bookmarks.services.search_query_parser import (
parse_search_query,
strip_tag_from_query,
OrExpression,
SearchQueryParseError,
)
from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.type_defs import HttpRequest
from bookmarks.views import access
@@ -37,6 +43,16 @@ class RequestContext:
self.query_params = request.GET.copy()
self.query_params.pop("details", None)
self.query_is_valid = True
self.query_error_message = None
self.search_expression = None
if not request.user_profile.legacy_search:
try:
self.search_expression = parse_search_query(request.GET.get("q"))
except SearchQueryParseError as e:
self.query_is_valid = False
self.query_error_message = e.message
def get_url(self, view_url: str, add: dict = None, remove: dict = None) -> str:
query_params = self.query_params.copy()
if add:
@@ -131,6 +147,8 @@ class BookmarkItem:
self.description = bookmark.resolved_description
self.notes = bookmark.notes
self.tag_names = bookmark.tag_names
self.tags = [AddTagItem(context, tag) for tag in bookmark.tags.all()]
self.tags.sort(key=lambda item: item.name)
if bookmark.latest_snapshot_id:
self.snapshot_url = reverse(
"linkding:assets.view", args=[bookmark.latest_snapshot_id]
@@ -186,6 +204,8 @@ class BookmarkListContext:
self.request = request
self.search = search
self.query_is_valid = request_context.query_is_valid
self.query_error_message = request_context.query_error_message
query_set = request_context.get_bookmark_query_set(self.search)
page_number = request.GET.get("page")
@@ -257,58 +277,168 @@ class SharedBookmarkListContext(BookmarkListContext):
request_context = SharedBookmarksContext
class AddTagItem:
def __init__(self, context: RequestContext, tag: Tag):
self.tag = tag
self.name = tag.name
params = context.query_params.copy()
query_with_tag = params.get("q", "")
if isinstance(context.search_expression, OrExpression):
# If the current search expression is an OR expression, wrap in parentheses
query_with_tag = f"({query_with_tag})"
query_with_tag = f"{query_with_tag} #{tag.name}".strip()
params["q"] = query_with_tag
params.pop("details", None)
params.pop("page", None)
if context.request.user_profile.legacy_search:
self.query_string = self._generate_query_string_legacy(context, tag)
else:
self.query_string = self._generate_query_string(context, tag)
@staticmethod
def _generate_query_string(context: RequestContext, tag: Tag) -> str:
params = context.query_params.copy()
query_with_tag = params.get("q", "")
if isinstance(context.search_expression, OrExpression):
# If the current search expression is an OR expression, wrap in parentheses
query_with_tag = f"({query_with_tag})"
query_with_tag = f"{query_with_tag} #{tag.name}".strip()
params["q"] = query_with_tag
params.pop("details", None)
params.pop("page", None)
return params.urlencode()
@staticmethod
def _generate_query_string_legacy(context: RequestContext, tag: Tag) -> str:
params = context.query_params.copy()
query_with_tag = params.get("q", "")
query_with_tag = f"{query_with_tag} #{tag.name}".strip()
params["q"] = query_with_tag
params.pop("details", None)
params.pop("page", None)
return params.urlencode()
class RemoveTagItem:
def __init__(self, context: RequestContext, tag: Tag):
self.tag = tag
self.name = tag.name
if context.request.user_profile.legacy_search:
self.query_string = self._generate_query_string_legacy(context, tag)
else:
self.query_string = self._generate_query_string(context, tag)
@staticmethod
def _generate_query_string(context: RequestContext, tag: Tag) -> str:
params = context.query_params.copy()
query = params.get("q", "")
profile = context.request.user_profile
query_without_tag = strip_tag_from_query(query, tag.name, profile)
params["q"] = query_without_tag
params.pop("details", None)
params.pop("page", None)
return params.urlencode()
@staticmethod
def _generate_query_string_legacy(context: RequestContext, tag: Tag) -> str:
params = context.request.GET.copy()
if params.__contains__("q"):
# Split query string into parts
query_string = params.__getitem__("q")
query_parts = query_string.split()
# Remove tag with hash
tag_name_with_hash = "#" + tag.name
query_parts = [
part
for part in query_parts
if str.lower(part) != str.lower(tag_name_with_hash)
]
# When using lax tag search, also remove tag without hash
profile = context.request.user_profile
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
query_parts = [
part
for part in query_parts
if str.lower(part) != str.lower(tag.name)
]
# Rebuild query string
query_string = " ".join(query_parts)
params.__setitem__("q", query_string)
# Remove details ID and page number
params.pop("details", None)
params.pop("page", None)
return params.urlencode()
class TagGroup:
def __init__(self, char: str):
def __init__(self, context: RequestContext, char: str):
self.context = context
self.tags = []
self.char = char
def __repr__(self):
return f"<{self.char} TagGroup>"
def add_tag(self, tag: Tag):
self.tags.append(AddTagItem(self.context, tag))
@staticmethod
def create_tag_groups(mode: str, tags: Set[Tag]):
def create_tag_groups(context: RequestContext, mode: str, tags: Set[Tag]):
if mode == UserProfile.TAG_GROUPING_ALPHABETICAL:
return TagGroup._create_tag_groups_alphabetical(tags)
return TagGroup._create_tag_groups_alphabetical(context, tags)
elif mode == UserProfile.TAG_GROUPING_DISABLED:
return TagGroup._create_tag_groups_disabled(tags)
return TagGroup._create_tag_groups_disabled(context, tags)
else:
raise ValueError(f"{mode} is not a valid tag grouping mode")
@staticmethod
def _create_tag_groups_alphabetical(tags: Set[Tag]):
def _create_tag_groups_alphabetical(context: RequestContext, tags: Set[Tag]):
# Ensure groups, as well as tags within groups, are ordered alphabetically
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = None
groups = []
cjk_used = False
cjk_group = TagGroup("Ideographic")
cjk_group = TagGroup(context, "Ideographic")
# Group tags that start with a different character than the previous one
for tag in sorted_tags:
tag_char = tag.name[0].lower()
if CJK_RE.match(tag_char):
cjk_used = True
cjk_group.tags.append(tag)
cjk_group.add_tag(tag)
elif not group or group.char != tag_char:
group = TagGroup(tag_char)
group = TagGroup(context, tag_char)
groups.append(group)
group.tags.append(tag)
group.add_tag(tag)
else:
group.tags.append(tag)
group.add_tag(tag)
if cjk_used:
groups.append(cjk_group)
return groups
@staticmethod
def _create_tag_groups_disabled(tags: Set[Tag]):
def _create_tag_groups_disabled(context: RequestContext, tags: Set[Tag]):
if len(tags) == 0:
return []
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
group = TagGroup("Ungrouped")
group = TagGroup(context, "Ungrouped")
for tag in sorted_tags:
group.tags.append(tag)
group.add_tag(tag)
return [group]
@@ -325,21 +455,30 @@ class TagCloudContext:
query_set = request_context.get_tag_query_set(self.search)
tags = list(query_set)
selected_tags = self.get_selected_tags(tags)
selected_tags = self.get_selected_tags()
unique_tags = utils.unique(tags, key=lambda x: str.lower(x.name))
unique_selected_tags = utils.unique(
selected_tags, key=lambda x: str.lower(x.name)
)
has_selected_tags = len(unique_selected_tags) > 0
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
groups = TagGroup.create_tag_groups(user_profile.tag_grouping, unselected_tags)
groups = TagGroup.create_tag_groups(
request_context, user_profile.tag_grouping, unselected_tags
)
selected_tag_items = []
for tag in unique_selected_tags:
selected_tag_items.append(RemoveTagItem(request_context, tag))
self.tags = unique_tags
self.groups = groups
self.selected_tags = unique_selected_tags
self.selected_tags = selected_tag_items
self.has_selected_tags = has_selected_tags
def get_selected_tags(self, tags: List[Tag]):
def get_selected_tags(self):
raise NotImplementedError("Must be implemented by subclass")
def get_selected_tags_legacy(self, tags: List[Tag]):
parsed_query = queries.parse_query_string(self.search.q)
tag_names = parsed_query["tag_names"]
if self.request.user_profile.tag_search == UserProfile.TAG_SEARCH_LAX:
@@ -352,14 +491,37 @@ class TagCloudContext:
class ActiveTagCloudContext(TagCloudContext):
request_context = ActiveBookmarksContext
def get_selected_tags(self):
return list(
queries.get_tags_for_query(
self.request.user, self.request.user_profile, self.search.q
)
)
class ArchivedTagCloudContext(TagCloudContext):
request_context = ArchivedBookmarksContext
def get_selected_tags(self):
return list(
queries.get_tags_for_query(
self.request.user, self.request.user_profile, self.search.q
)
)
class SharedTagCloudContext(TagCloudContext):
request_context = SharedBookmarksContext
def get_selected_tags(self):
user = User.objects.filter(username=self.search.user).first()
public_only = not self.request.user.is_authenticated
return list(
queries.get_shared_tags_for_query(
user, self.request.user_profile, self.search.q, public_only
)
)
class BookmarkAssetItem:
def __init__(self, asset: BookmarkAsset):
@@ -403,6 +565,9 @@ class BookmarkDetailsContext:
self.close_url = request_context.index()
self.bookmark = bookmark
self.tags = [AddTagItem(request_context, tag) for tag in bookmark.tags.all()]
self.tags.sort(key=lambda item: item.name)
self.profile = request.user_profile
self.is_editable = bookmark.owner == user
self.sharing_enabled = user_profile.enable_sharing

View File

@@ -31,6 +31,7 @@ export default defineConfig({
label: "Guides",
items: [
{ label: "Backups", slug: "backups" },
//{ label: "Bookmark Search", slug: "search" },
{ label: "Archiving", slug: "archiving" },
{ label: "Auto Tagging", slug: "auto-tagging" },
{ label: "Keyboard Shortcuts", slug: "shortcuts" },

View File

@@ -0,0 +1,74 @@
---
title: Bookmark Search
---
linkding provides a comprehensive search function for finding bookmarks. This guide gives on overview of the search capabilities and provides some examples.
## Search Expressions
Every search query is made up of one or more expressions. An expression can be a single word, a phrase, a tag, or a combination of these using boolean operators. The table below summarizes the different expression types:
| Expression | Example | Description |
|--------------|------------------------------------|------------------------------------------------------------|
| Word | `history` | Search for a single word in title, description, notes, URL |
| Phrase | `"history of rome"` | Search for an exact phrase by enclosing it in quotes |
| Tag | `#book` | Search for tag |
| AND operator | `#history and #book` | Both expressions must match |
| OR operator | `#book or #article` | Either expression must match |
| NOT operator | `not #article` | Expression must not match |
| Grouping | `#history and (#book or #article)` | Control evaluation order using parenthesis |
When combining multiple words, phrases or tags without an explicit operator, the `and` operator is assumed. For example:
```
history rome #book
```
is equivalent to:
```
history and rome and #book
```
Some additional rules to keep in mind:
- Words, phrases, tags, and operators are all case-insensitive.
- Tags must be prefixed with a `#` symbol. If the *lax* tag search mode is enabled in the settings, the `#` prefix is optional. In that case searching for a word will return both bookmarks containing that word or bookmarks tagged with that word.
- An operator (`and`, `or`, `not`) can not be used as a search term as such. To explicitly search for these words, use a phrase: `"beyond good and evil"`, `"good or bad"`, `"not found"`.
## Examples
Here are some example search queries and their meanings:
```
history rome #book
```
Search bookmarks that contain both "history" and "rome", and are tagged with "book".
```
"history of rome" #book
```
Search bookmarks that contain the exact phrase "history of rome" and are tagged with "book".
```
#article or #book
```
Search bookmarks that are tagged with either "article" or "book".
```
rome (#article or #book)
```
Search bookmarks that contain "rome" and are tagged with either "article" or "book".
```
history rome not #article
```
Search bookmarks that contain both "history" and "rome", but are not tagged with "article".
```
history rome not (#article or #book)
```
Search bookmarks that contain both "history" and "rome", but are not tagged with either "article" or "book".
## Legacy Search
A new search engine that supports the above expressions was introduced in linkding v1.44.0.
If you run into any issues with the new search, you can switch back to the old one by enabling legacy search in the settings.
Please report any issues you encounter with the new search on [GitHub](https://github.com/sissbruecker/linkding/issues) so they can be addressed.
This option will be removed in a future version.