From d72cf1e5645f6c72dede0c431cffe2c088e8e126 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Tue, 3 Nov 2026 17:02:54 +0100 Subject: [PATCH 1/2] JSPWIKI-1321 Match wildcards inside the words of a page name and keep the relevance up to 100 A term of the name field with a * between other characters, such as blog*26, is also looked for inside the words of the name, and its parts in order in different words. The relevance of a result is scaled so that the best one is 100. --- .../wiki/search/LuceneSearchProvider.java | 33 ++++++++++++- .../apache/wiki/search/SearchManagerTest.java | 48 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/jspwiki-main/src/main/java/org/apache/wiki/search/LuceneSearchProvider.java b/jspwiki-main/src/main/java/org/apache/wiki/search/LuceneSearchProvider.java index 45dfc9676b..2729f6a448 100644 --- a/jspwiki-main/src/main/java/org/apache/wiki/search/LuceneSearchProvider.java +++ b/jspwiki-main/src/main/java/org/apache/wiki/search/LuceneSearchProvider.java @@ -503,7 +503,7 @@ public Collection< SearchResult > findPages( final String query, final int flags if( page != null ) { final PagePermission pp = new PagePermission( page, PagePermission.VIEW_ACTION ); if( mgr.checkPermission( wikiContext.getWikiSession(), pp ) ) { - final int score = ( int ) ( hit.score * 100 ); + final int score = hits.scoreDocs[ 0 ].score > 0 ? Math.round( hit.score / hits.scoreDocs[ 0 ].score * 100 ) : 0; // Get highlighted search contexts final String text = doc.get( LUCENE_PAGE_CONTENTS ); @@ -604,6 +604,7 @@ static final class SeparatorAwareQueryParser extends MultiFieldQueryParser { private static final String NON_SEPARATORS = "[\\p{L}\\p{N}*?]*"; private static final String SEPARATORS = "[^\\p{L}\\p{N}*?]+"; + private static final int MAX_WORDS_BETWEEN_PARTS = 1000; SeparatorAwareQueryParser( final String[] fields, final Analyzer analyzer ) { super( fields, analyzer ); @@ -624,6 +625,12 @@ protected Query getPrefixQuery( final String field, final String termStr ) throw @Override protected Query getWildcardQuery( final String field, final String termStr ) throws ParseException { + final Query query = separatedWildcardQuery( field, termStr ); + final Query inside = LUCENE_PAGE_NAME.equals( field ) ? insideWords( field, termStr ) : null; + return inside == null ? query : either( query, inside ); + } + + private Query separatedWildcardQuery( final String field, final String termStr ) throws ParseException { final Query separated = field != null ? separated( field, termStr, false ) : null; if( separated == null ) { return super.getWildcardQuery( field, termStr ); @@ -635,6 +642,30 @@ protected Query getWildcardQuery( final String field, final String termStr ) thr } } + /** + * Builds the query for a page name term with a {@code *} between other characters, such as {@code blog*26}. Such a term is also + * looked for inside the words of the name, and its parts are looked for in order in different words, so {@code entry*26*} finds + * {@code blogentry_021126_1}. Returns {@code null} for any other term, which keeps the words it has to start with. + */ + private Query insideWords( final String field, final String termStr ) { + if( !termStr.matches( ".+\\*.+" ) ) { + return null; + } + + final String inside = "*" + StringUtils.strip( termStr, "*" ) + "*"; + final BooleanQuery.Builder query = new BooleanQuery.Builder() + .add( new WildcardQuery( new Term( field, getAnalyzer().normalize( field, inside ) ) ), BooleanClause.Occur.SHOULD ); + + final String[] parts = Arrays.stream( termStr.split( "\\*+" ) ).filter( part -> !part.isEmpty() ).toArray( String[]::new ); + if( parts.length > 1 && Arrays.stream( parts ).noneMatch( part -> part.matches( ".*" + SEPARATORS + ".*" ) ) ) { + final SpanQuery[] clauses = Arrays.stream( parts ) + .map( part -> new SpanMultiTermQueryWrapper<>( new WildcardQuery( new Term( field, getAnalyzer().normalize( field, "*" + part + "*" ) ) ) ) ) + .toArray( SpanQuery[]::new ); + query.add( new SpanNearQuery( clauses, MAX_WORDS_BETWEEN_PARTS, true ), BooleanClause.Occur.SHOULD ); + } + return query.build(); + } + /** * Matches what either query matches. The default query is kept because the analyzer does not always split on a separator, so * a token like {@code blogentry_021126_1} can hold it and only the default query finds it. diff --git a/jspwiki-main/src/test/java/org/apache/wiki/search/SearchManagerTest.java b/jspwiki-main/src/test/java/org/apache/wiki/search/SearchManagerTest.java index 4ad689cedf..835434f4c9 100644 --- a/jspwiki-main/src/test/java/org/apache/wiki/search/SearchManagerTest.java +++ b/jspwiki-main/src/test/java/org/apache/wiki/search/SearchManagerTest.java @@ -284,6 +284,54 @@ void testWildcardWithoutUnderscoreIsUnchanged() throws Exception { Assertions.assertEquals( Arrays.asList(), pageNamesFor( "woman*" ) ); } + /** https://issues.apache.org/jira/browse/JSPWIKI-1321 */ + @Test + void testWildcardInsideTheWordsOfPageName() throws Exception { + m_engine.saveText( "Weblog_blogentry_021126_1", "first entry" ); + m_engine.saveText( "Weblog_blogentry_031226_1", "second entry" ); + m_engine.saveText( "Weblog_blogentry_021127_2", "third entry" ); + m_engine.saveText( "Man_Page", "a" ); + m_engine.saveText( "Human_Page", "b" ); + m_engine.saveText( "Woman_Page", "c" ); + Awaitility.await( "index" ).until( findsResultsFor( new ArrayList<>(), "name:weblog" ) ); + Awaitility.await( "index2" ).until( findsResultsFor( new ArrayList<>(), "name:woman" ) ); + + final List< String > withTwentySix = Arrays.asList( "Weblog_blogentry_021126_1", "Weblog_blogentry_031226_1" ); + Assertions.assertEquals( withTwentySix, pageNamesFor( "name:blog*26" ) ); + Assertions.assertEquals( withTwentySix, pageNamesFor( "name:entry*26*" ) ); + Assertions.assertEquals( withTwentySix, pageNamesFor( "name:blog*26*" ) ); + Assertions.assertEquals( Arrays.asList( "Weblog_blogentry_021127_2" ), pageNamesFor( "name:entry*27*" ) ); + Assertions.assertEquals( Arrays.asList(), pageNamesFor( "name:entry*99*" ) ); + } + + /** https://issues.apache.org/jira/browse/JSPWIKI-1321 */ + @Test + void testWildcardAtTheEndOfTermStillStartsAtTheWord() throws Exception { + m_engine.saveText( "Man_Page", "a" ); + m_engine.saveText( "Human_Page", "b" ); + m_engine.saveText( "Woman_Page", "c" ); + Awaitility.await( "index" ).until( findsResultsFor( new ArrayList<>(), "name:woman" ) ); + + Assertions.assertEquals( Arrays.asList( "Man_Page" ), pageNamesFor( "name:man*" ) ); + Assertions.assertEquals( Arrays.asList( "Human_Page", "Man_Page", "Woman_Page" ), pageNamesFor( "name:man*pa*" ) ); + } + + /** https://issues.apache.org/jira/browse/JSPWIKI-1321 */ + @Test + void testRelevanceIsBetweenZeroAndOneHundred() throws Exception { + m_engine.saveText( "Few", "mankind" ); + m_engine.saveText( "Many", "mankind mankind mankind mankind mankind mankind mankind mankind" ); + m_engine.saveText( "Padded", "mankind and a good many other words to lower the score of this page in the index" ); + Awaitility.await( "index" ).until( findsResultsFor( new ArrayList<>(), "Padded" ) ); + + final Collection< SearchResult > res = new ArrayList<>(); + Awaitility.await( "scores" ).until( findsResultsFor( res, "mankind" ) ); + final int max = res.stream().mapToInt( SearchResult::getScore ).max().getAsInt(); + Assertions.assertEquals( 3, res.size() ); + Assertions.assertEquals( 100, max ); + Assertions.assertTrue( res.stream().allMatch( r -> r.getScore() >= 0 && r.getScore() <= 100 ) ); + } + @Test public void getPluginList() throws Exception { DefaultSearchManager defaultSearchManager = new DefaultSearchManager(m_engine, m_engine.getWikiProperties()); From 8563bda673daa1d297b024d4b673762d6bfa4945 Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Tue, 3 Nov 2026 17:24:07 +0100 Subject: [PATCH 2/2] JSPWIKI-1321 Add the entry of the change to the ChangeLog --- ChangeLog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ChangeLog.md b/ChangeLog.md index 6950e04176..2a12bf1326 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -39,6 +39,7 @@ under the License. * Fix for [JSPWIKI-922](https://issues.apache.org/jira/browse/JSPWIKI-922): the `Content-Disposition` header of an attachment download carried a name with non-ASCII characters as a raw byte, which is not valid UTF-8; such names get an ASCII fallback and a `filename*` parameter with the UTF-8 name now ([PR#571](https://github.com/apache/jspwiki/pull/571)) * Fix for [JSPWIKI-501](https://issues.apache.org/jira/browse/JSPWIKI-501): wildcard and prefix searches that contain an underscore find the pages ([PR#543](https://github.com/apache/jspwiki/pull/543)) +* Improvement [JSPWIKI-1321](https://issues.apache.org/jira/browse/JSPWIKI-1321): wildcard terms with a * in the middle find the words inside a page name, and the relevance of a search result is at most 100 ([PR#575](https://github.com/apache/jspwiki/pull/575)) * Fix for [JSPWIKI-1138](https://issues.apache.org/jira/browse/JSPWIKI-1138): Install.jsp echoed submitted values without escaping them; they are escaped now ([PR#323](https://github.com/apache/jspwiki/pull/323)) * Fix for [JSPWIKI-1164](https://issues.apache.org/jira/browse/JSPWIKI-1164): a page reached from a search result showed `<` as `<` when the query was highlighted ([PR#538](https://github.com/apache/jspwiki/pull/538)) * Fix for [JSPWIKI-919](https://issues.apache.org/jira/browse/JSPWIKI-919): `VersioningFileProvider#pageExists()` ignored the first version of a page that has no history, so a page dropped into the page directory was reported as missing when the page cache is off ([PR#539](https://github.com/apache/jspwiki/pull/539))