Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified app/client/public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions app/client/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@
name="description"
content="Luck Info Hunter"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Expand Down
17 changes: 17 additions & 0 deletions app/client/src/domain/searchSyntax.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { formatAdvancedSearchText, formatAdvancedSearchValue } from "./searchSyntax";

test('formatAdvancedSearchValue keeps simple values unquoted', () => {
expect(formatAdvancedSearchValue('Inbox')).toBe('Inbox');
});

test('formatAdvancedSearchValue quotes values with spaces', () => {
expect(formatAdvancedSearchValue('Daily Reads')).toBe('"Daily Reads"');
});

test('formatAdvancedSearchValue escapes quotes inside quoted values', () => {
expect(formatAdvancedSearchValue('Daily "Reads"')).toBe('"Daily \\"Reads\\""');
});

test('formatAdvancedSearchText returns prefixed text with trailing space', () => {
expect(formatAdvancedSearchText('collection:', 'Daily Reads')).toBe('collection:"Daily Reads" ');
});
18 changes: 18 additions & 0 deletions app/client/src/domain/searchSyntax.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function formatAdvancedSearchValue(value: string) {
const trimmedValue = value.trim();

if (!trimmedValue) {
return '';
}

if (!/[\s"]/.test(trimmedValue)) {
return trimmedValue;
}

return `"${trimmedValue.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}

export function formatAdvancedSearchText(prefix: string, value: string) {
const formattedValue = formatAdvancedSearchValue(value);
return formattedValue ? `${prefix}${formattedValue} ` : undefined;
}
3 changes: 2 additions & 1 deletion app/client/src/pages/CollectionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import InboxOutlinedIcon from "@mui/icons-material/InboxOutlined";
import { Icon } from "@iconify/react";
import { Box, SvgIconProps } from "@mui/material";
import { useTranslation } from "react-i18next";
import { formatAdvancedSearchText } from "../domain/searchSyntax";

// Custom icon component for collections that can display emoji, iconify icons, or default folder
const CollectionIconComponent = React.forwardRef<SVGSVGElement, SvgIconProps & { collectionIcon?: string | null }>(
Expand Down Expand Up @@ -124,7 +125,7 @@ const CollectionList = () => {
// Add trailing space so users can directly type additional keywords
const searchText = useMemo(() => {
if (!isUnsorted && collection?.name) {
return `collection:${collection.name} `;
return formatAdvancedSearchText('collection:', collection.name);
}
return undefined;
}, [isUnsorted, collection?.name]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,11 +447,11 @@ public PageSearchResult searchPages(@NonNull SearchQuery searchQuery) {
return searchResult;
}

private CompleteSearch extractCompleteSearch(String keyword) {
CompleteSearch extractCompleteSearch(String keyword) {
CompleteSearch completeSearch = new CompleteSearch();
completeSearch.setAdvancedSearches(new ArrayList<>());
completeSearch.setCollectionIds(new ArrayList<>());
String[] keywords = keyword.split(" ");
List<String> keywords = splitSearchTokens(keyword);
Copy link
Copy Markdown

@augmentcode augmentcode Bot Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With splitSearchTokens preserving whitespace inside quoted phrases, collection: values can now include leading/trailing spaces from inside the quotes, and the later findByNameContainingIgnoreCase(collectionName) lookup may fail to match unexpectedly. Consider normalizing the extracted collection value before querying (e.g., trimming).

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

List<String> simpleWords = new ArrayList<>();
for (String key : keywords) {
if (StringUtils.isBlank(key)) {
Expand Down Expand Up @@ -482,14 +482,70 @@ private CompleteSearch extractCompleteSearch(String keyword) {
private AdvancedSearch extractAdvancedSearch(String key, String docField, String seperator) {
AdvancedSearch advancedSearch = new AdvancedSearch();
advancedSearch.setDocField(docField);
String[] parts = key.split(seperator);
if (parts.length == 2) {
advancedSearch.setKeyword(parts[1]);
}
advancedSearch.setKeyword(extractAdvancedSearchKeyword(key, seperator));
advancedSearch.words = segmentWords(advancedSearch.getKeyword(), true);
return advancedSearch;
}

static String extractAdvancedSearchKeyword(String key, String separator) {
int separatorIndex = key.indexOf(separator);
if (separatorIndex < 0) {
return "";
}
return key.substring(separatorIndex + separator.length()).trim();
}

static List<String> splitSearchTokens(String keyword) {
List<String> tokens = new ArrayList<>();
if (StringUtils.isBlank(keyword)) {
return tokens;
}

StringBuilder token = new StringBuilder();
boolean inQuotes = false;
boolean escaping = false;

for (int i = 0; i < keyword.length(); i++) {
char current = keyword.charAt(i);

if (escaping) {
token.append(current);
escaping = false;
continue;
}

if (inQuotes && current == '\\') {
escaping = true;
continue;
}

if (current == '"') {
inQuotes = !inQuotes;
continue;
}

if (!inQuotes && Character.isWhitespace(current)) {
if (token.length() > 0) {
tokens.add(token.toString());
token.setLength(0);
}
continue;
}

token.append(current);
}

if (escaping) {
token.append('\\');
}

if (token.length() > 0) {
tokens.add(token.toString());
}

return tokens;
}

private SearchOption parseSearchOption(String options) {
SearchOption option = new SearchOption();
if (StringUtils.isNotBlank(options)) {
Expand Down Expand Up @@ -542,6 +598,9 @@ private SearchOption parseSearchOption(String options) {

private List<String> segmentWords(String keyword, boolean useSmart) {
List<String> words = new ArrayList<>();
if (StringUtils.isBlank(keyword)) {
return words;
}
StringReader sr = new StringReader(keyword);
IKSegmenter segmenter = new IKSegmenter(sr, useSmart);
Lexeme lexeme = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.huntly.server.service;

import com.huntly.server.config.HuntlyProperties;
import com.huntly.server.domain.entity.Collection;
import com.huntly.server.repository.CollectionRepository;
import com.huntly.server.repository.PageRepository;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class LuceneServiceSearchSyntaxTest {

@Test
void splitSearchTokens_keepsQuotedAdvancedSearchValueTogether() {
List<String> tokens = LuceneService.splitSearchTokens("machine collection:\"Daily Reads\" author:\"Jane Doe\"");

assertThat(tokens).containsExactly("machine", "collection:Daily Reads", "author:Jane Doe");
}

@Test
void splitSearchTokens_unescapesQuotedCharactersInsideQuotedValue() {
List<String> tokens = LuceneService.splitSearchTokens("collection:\"Daily \\\"Reads\\\"\"");

assertThat(tokens).containsExactly("collection:Daily \"Reads\"");
}

@Test
void extractAdvancedSearchKeyword_usesFirstSeparatorOnly() {
String keyword = LuceneService.extractAdvancedSearchKeyword("url:https://example.com/a:b", ":");

assertThat(keyword).isEqualTo("https://example.com/a:b");
}

@Test
void extractCompleteSearch_usesQuotedCollectionNameForCollectionFilter() {
CollectionRepository collectionRepository = mock(CollectionRepository.class);
Collection collection = new Collection();
collection.setId(42L);
collection.setName("Daily Reads");
when(collectionRepository.findByNameContainingIgnoreCase("Daily Reads")).thenReturn(List.of(collection));

LuceneService luceneService = new LuceneService(
mock(PageRepository.class),
mock(PageListService.class),
new HuntlyProperties(),
collectionRepository
);

LuceneService.CompleteSearch completeSearch = luceneService.extractCompleteSearch("machine collection:\"Daily Reads\"");

assertThat(completeSearch.getKeyword()).isEqualTo("machine");
assertThat(completeSearch.getCollectionIds()).containsExactly(42L);
}
}
Loading