Skip to content

Commit fa27e1e

Browse files
authored
Merge pull request #151 from lcomplete/dev
feat(search): support quoted phrases in advanced search syntax
2 parents 2217099 + fa2c7e4 commit fa27e1e

7 files changed

Lines changed: 162 additions & 9 deletions

File tree

-3.92 KB
Loading

app/client/public/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@
1111
name="description"
1212
content="Luck Info Hunter"
1313
/>
14-
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
14+
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
1515
<!--
1616
manifest.json provides metadata used when your web app is installed on a
1717
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
1818
-->
19-
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
19+
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" />
2020
<!--
2121
Notice the use of %PUBLIC_URL% in the tags above.
2222
It will be replaced with the URL of the `public` folder during the build.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { formatAdvancedSearchText, formatAdvancedSearchValue } from "./searchSyntax";
2+
3+
test('formatAdvancedSearchValue keeps simple values unquoted', () => {
4+
expect(formatAdvancedSearchValue('Inbox')).toBe('Inbox');
5+
});
6+
7+
test('formatAdvancedSearchValue quotes values with spaces', () => {
8+
expect(formatAdvancedSearchValue('Daily Reads')).toBe('"Daily Reads"');
9+
});
10+
11+
test('formatAdvancedSearchValue escapes quotes inside quoted values', () => {
12+
expect(formatAdvancedSearchValue('Daily "Reads"')).toBe('"Daily \\"Reads\\""');
13+
});
14+
15+
test('formatAdvancedSearchText returns prefixed text with trailing space', () => {
16+
expect(formatAdvancedSearchText('collection:', 'Daily Reads')).toBe('collection:"Daily Reads" ');
17+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export function formatAdvancedSearchValue(value: string) {
2+
const trimmedValue = value.trim();
3+
4+
if (!trimmedValue) {
5+
return '';
6+
}
7+
8+
if (!/[\s"]/.test(trimmedValue)) {
9+
return trimmedValue;
10+
}
11+
12+
return `"${trimmedValue.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
13+
}
14+
15+
export function formatAdvancedSearchText(prefix: string, value: string) {
16+
const formattedValue = formatAdvancedSearchValue(value);
17+
return formattedValue ? `${prefix}${formattedValue} ` : undefined;
18+
}

app/client/src/pages/CollectionList.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import InboxOutlinedIcon from "@mui/icons-material/InboxOutlined";
1111
import { Icon } from "@iconify/react";
1212
import { Box, SvgIconProps } from "@mui/material";
1313
import { useTranslation } from "react-i18next";
14+
import { formatAdvancedSearchText } from "../domain/searchSyntax";
1415

1516
// Custom icon component for collections that can display emoji, iconify icons, or default folder
1617
const CollectionIconComponent = React.forwardRef<SVGSVGElement, SvgIconProps & { collectionIcon?: string | null }>(
@@ -124,7 +125,7 @@ const CollectionList = () => {
124125
// Add trailing space so users can directly type additional keywords
125126
const searchText = useMemo(() => {
126127
if (!isUnsorted && collection?.name) {
127-
return `collection:${collection.name} `;
128+
return formatAdvancedSearchText('collection:', collection.name);
128129
}
129130
return undefined;
130131
}, [isUnsorted, collection?.name]);

app/server/huntly-server/src/main/java/com/huntly/server/service/LuceneService.java

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -447,11 +447,11 @@ public PageSearchResult searchPages(@NonNull SearchQuery searchQuery) {
447447
return searchResult;
448448
}
449449

450-
private CompleteSearch extractCompleteSearch(String keyword) {
450+
CompleteSearch extractCompleteSearch(String keyword) {
451451
CompleteSearch completeSearch = new CompleteSearch();
452452
completeSearch.setAdvancedSearches(new ArrayList<>());
453453
completeSearch.setCollectionIds(new ArrayList<>());
454-
String[] keywords = keyword.split(" ");
454+
List<String> keywords = splitSearchTokens(keyword);
455455
List<String> simpleWords = new ArrayList<>();
456456
for (String key : keywords) {
457457
if (StringUtils.isBlank(key)) {
@@ -482,14 +482,70 @@ private CompleteSearch extractCompleteSearch(String keyword) {
482482
private AdvancedSearch extractAdvancedSearch(String key, String docField, String seperator) {
483483
AdvancedSearch advancedSearch = new AdvancedSearch();
484484
advancedSearch.setDocField(docField);
485-
String[] parts = key.split(seperator);
486-
if (parts.length == 2) {
487-
advancedSearch.setKeyword(parts[1]);
488-
}
485+
advancedSearch.setKeyword(extractAdvancedSearchKeyword(key, seperator));
489486
advancedSearch.words = segmentWords(advancedSearch.getKeyword(), true);
490487
return advancedSearch;
491488
}
492489

490+
static String extractAdvancedSearchKeyword(String key, String separator) {
491+
int separatorIndex = key.indexOf(separator);
492+
if (separatorIndex < 0) {
493+
return "";
494+
}
495+
return key.substring(separatorIndex + separator.length()).trim();
496+
}
497+
498+
static List<String> splitSearchTokens(String keyword) {
499+
List<String> tokens = new ArrayList<>();
500+
if (StringUtils.isBlank(keyword)) {
501+
return tokens;
502+
}
503+
504+
StringBuilder token = new StringBuilder();
505+
boolean inQuotes = false;
506+
boolean escaping = false;
507+
508+
for (int i = 0; i < keyword.length(); i++) {
509+
char current = keyword.charAt(i);
510+
511+
if (escaping) {
512+
token.append(current);
513+
escaping = false;
514+
continue;
515+
}
516+
517+
if (inQuotes && current == '\\') {
518+
escaping = true;
519+
continue;
520+
}
521+
522+
if (current == '"') {
523+
inQuotes = !inQuotes;
524+
continue;
525+
}
526+
527+
if (!inQuotes && Character.isWhitespace(current)) {
528+
if (token.length() > 0) {
529+
tokens.add(token.toString());
530+
token.setLength(0);
531+
}
532+
continue;
533+
}
534+
535+
token.append(current);
536+
}
537+
538+
if (escaping) {
539+
token.append('\\');
540+
}
541+
542+
if (token.length() > 0) {
543+
tokens.add(token.toString());
544+
}
545+
546+
return tokens;
547+
}
548+
493549
private SearchOption parseSearchOption(String options) {
494550
SearchOption option = new SearchOption();
495551
if (StringUtils.isNotBlank(options)) {
@@ -542,6 +598,9 @@ private SearchOption parseSearchOption(String options) {
542598

543599
private List<String> segmentWords(String keyword, boolean useSmart) {
544600
List<String> words = new ArrayList<>();
601+
if (StringUtils.isBlank(keyword)) {
602+
return words;
603+
}
545604
StringReader sr = new StringReader(keyword);
546605
IKSegmenter segmenter = new IKSegmenter(sr, useSmart);
547606
Lexeme lexeme = null;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.huntly.server.service;
2+
3+
import com.huntly.server.config.HuntlyProperties;
4+
import com.huntly.server.domain.entity.Collection;
5+
import com.huntly.server.repository.CollectionRepository;
6+
import com.huntly.server.repository.PageRepository;
7+
import org.junit.jupiter.api.Test;
8+
9+
import java.util.List;
10+
11+
import static org.assertj.core.api.Assertions.assertThat;
12+
import static org.mockito.Mockito.mock;
13+
import static org.mockito.Mockito.when;
14+
15+
class LuceneServiceSearchSyntaxTest {
16+
17+
@Test
18+
void splitSearchTokens_keepsQuotedAdvancedSearchValueTogether() {
19+
List<String> tokens = LuceneService.splitSearchTokens("machine collection:\"Daily Reads\" author:\"Jane Doe\"");
20+
21+
assertThat(tokens).containsExactly("machine", "collection:Daily Reads", "author:Jane Doe");
22+
}
23+
24+
@Test
25+
void splitSearchTokens_unescapesQuotedCharactersInsideQuotedValue() {
26+
List<String> tokens = LuceneService.splitSearchTokens("collection:\"Daily \\\"Reads\\\"\"");
27+
28+
assertThat(tokens).containsExactly("collection:Daily \"Reads\"");
29+
}
30+
31+
@Test
32+
void extractAdvancedSearchKeyword_usesFirstSeparatorOnly() {
33+
String keyword = LuceneService.extractAdvancedSearchKeyword("url:https://example.com/a:b", ":");
34+
35+
assertThat(keyword).isEqualTo("https://example.com/a:b");
36+
}
37+
38+
@Test
39+
void extractCompleteSearch_usesQuotedCollectionNameForCollectionFilter() {
40+
CollectionRepository collectionRepository = mock(CollectionRepository.class);
41+
Collection collection = new Collection();
42+
collection.setId(42L);
43+
collection.setName("Daily Reads");
44+
when(collectionRepository.findByNameContainingIgnoreCase("Daily Reads")).thenReturn(List.of(collection));
45+
46+
LuceneService luceneService = new LuceneService(
47+
mock(PageRepository.class),
48+
mock(PageListService.class),
49+
new HuntlyProperties(),
50+
collectionRepository
51+
);
52+
53+
LuceneService.CompleteSearch completeSearch = luceneService.extractCompleteSearch("machine collection:\"Daily Reads\"");
54+
55+
assertThat(completeSearch.getKeyword()).isEqualTo("machine");
56+
assertThat(completeSearch.getCollectionIds()).containsExactly(42L);
57+
}
58+
}

0 commit comments

Comments
 (0)