WH-4315-offline-search

Support new format for search results; fix the display of json-based pages.
Merge-request: IJ-MR-101302
Merged-by: Egor Malyshev <egor.malyshev@jetbrains.com>

GitOrigin-RevId: b7cf90e0c5569f130b8c505e9936f0d0880095c4
This commit is contained in:
Egor Malyshev
2023-01-16 09:15:44 +00:00
committed by intellij-monorepo-bot
parent 91750d36b6
commit 45b31fae2a
11 changed files with 249 additions and 244 deletions
@@ -13,6 +13,7 @@ import java.nio.file.Files
import java.nio.file.Path
private const val MODULE_NAME = "intellij.platform.builtInHelp"
private val LUCENE_LIBRARIES = setOf("lucene-queryparser", "lucene-highlighter", "lucene-memory")
internal fun buildHelpPlugin(pluginVersion: String, context: BuildContext): PluginLayout? {
val productName = context.applicationInfo.productName
@@ -46,6 +47,7 @@ internal fun buildHelpPlugin(pluginVersion: String, context: BuildContext): Plug
content = pluginXml(buildContext, pluginVersion),
overwrite = true)
}
LUCENE_LIBRARIES.forEach { spec.withProjectLibrary(it) }
}
}
@@ -82,7 +84,7 @@ private suspend fun buildResourcesForHelpPlugin(resourceRoot: Path, classPath: L
spanBuilder("index help topics").useWithScope {
runIdea(context = context, mainClass = "com.jetbrains.builtInHelp.indexer.HelpIndexer",
args = listOf(resourceRoot.resolve("search").toString(),
resourceRoot.resolve("topics").toString()),
resourceRoot.resolve("topics").toString()),
jvmArgs = emptyList(),
classPath = classPath)
@@ -19,7 +19,6 @@
<orderEntry type="library" name="lucene-highlighter" level="project" />
<orderEntry type="library" name="lucene-queryparser" level="project" />
<orderEntry type="library" name="lucene-suggest" level="project" />
<orderEntry type="library" name="gson" level="project" />
<orderEntry type="library" name="commons-compress" level="project" />
<orderEntry type="library" name="jsoup" level="project" />
<orderEntry type="library" name="jaxb-api" level="project" />
@@ -31,5 +30,9 @@
<orderEntry type="library" name="netty-buffer" level="project" />
<orderEntry type="module" module-name="intellij.platform.ide.util.io" />
<orderEntry type="module" module-name="intellij.platform.ide.util.netty" />
<orderEntry type="library" name="jackson" level="project" />
<orderEntry type="library" name="jackson-databind" level="project" />
<orderEntry type="library" name="jackson-module-kotlin" level="project" />
<orderEntry type="module" module-name="intellij.maven.server.indexer" />
</component>
</module>
@@ -1,58 +1,104 @@
// Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.builtInHelp
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.intellij.openapi.util.text.StringUtil
import com.intellij.util.ResourceUtil
import com.jetbrains.builtInHelp.mapping.HelpMap
import com.jetbrains.builtInHelp.mapping.HelpMapId
import io.netty.channel.ChannelFutureListener
import io.netty.channel.ChannelHandlerContext
import io.netty.handler.codec.http.*
import org.jetbrains.builtInWebServer.BuiltInServerOptions
import org.jetbrains.io.addCommonHeaders
import org.jetbrains.io.send
import javax.xml.bind.JAXBContext
@Suppress("unused")
class HelpContentRequestHandler : HelpRequestHandlerBase() {
override fun process(urlDecoder: QueryStringDecoder, request: FullHttpRequest, context: ChannelHandlerContext): Boolean {
if (request.uri().contains("config.json")) {
private val propsToRemove = listOf(
"searchAlgoliaApiKey", "searchAlgoliaId",
"searchAlgoliaIndexName", "versionsService"
)
val config = HelpRequestHandlerBase::class.java.getResource("/topics/config.json")
.openStream().bufferedReader().use { it.readText() }.replace("https://data.services.jetbrains.com",
"http://127.0.0.1:${BuiltInServerOptions.getInstance().effectiveBuiltInServerPort}").replace(
"true", "false")
override fun process(
urlDecoder: QueryStringDecoder,
request: FullHttpRequest,
context: ChannelHandlerContext
): Boolean {
for (name: String in urlDecoder.parameters().keys) {
val param = urlDecoder.parameters()[name]
if (param != null && (param.isEmpty() || StringUtil.isEmpty(param[0]))) {
val mapStream = ResourceUtil.getResourceAsStream(
HelpContentRequestHandler::class.java.classLoader,
"topics", "Map.jhm"
)
val map: HelpMap = JAXBContext.newInstance(
HelpMap::class.java, HelpMapId::class.java
).createUnmarshaller().unmarshal(
mapStream
) as HelpMap
sendData(config.toByteArray(Charsets.UTF_8), "config.json", request, context.channel(), request.headers())
return true
val response = DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.TEMPORARY_REDIRECT)
response.headers().add(
"Location",
"http://127.0.0.1:${BuiltInServerOptions.getInstance().effectiveBuiltInServerPort}/help/${
map.getUrlForId(
name
)
}"
)
context.channel().writeAndFlush(response).addListener(ChannelFutureListener.CLOSE)
return true
}
}
if (urlDecoder.parameters().isNotEmpty()) {
for (name: String in urlDecoder.parameters().keys) {
val param = urlDecoder.parameters()[name]
if (param != null && (param.isEmpty() || StringUtil.isEmpty(param[0]))) {
val map: HelpMap = JAXBContext.newInstance(
HelpMap::class.java, HelpMapId::class.java).createUnmarshaller().unmarshal(
HelpRequestHandlerBase::class.java.getResource("/topics/Map.jhm")) as HelpMap
val response = DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.FOUND)
response.addCommonHeaders()
response.headers().add("Location", prefix + map.getUrlForId(name))
context.channel().writeAndFlush(response).addListener(ChannelFutureListener.CLOSE)
return true
when (val resourceName: String = urlDecoder.path().substringAfterLast("/")) {
"config.json" -> {
val configStream = ResourceUtil.getResourceAsStream(
HelpContentRequestHandler::class.java.classLoader,
"topics", "config.json"
)
@Suppress("UNCHECKED_CAST")
val configJson: LinkedHashMap<String, Any> = jacksonObjectMapper().readValue(
configStream,
LinkedHashMap::class.java
) as LinkedHashMap<String, Any>
configJson.keys.removeAll { propsToRemove.contains(it) }
configJson.keys.forEach {
if (configJson[it] is String) {
val current: String = configJson[it] as String
configJson[it] = current.replace(
"https://data.services.jetbrains.com",
"http://127.0.0.1:${BuiltInServerOptions.getInstance().effectiveBuiltInServerPort}"
).replace(
"true", "false"
)
}
}
sendData(
jacksonObjectMapper().writeValueAsBytes(configJson),
"config.json",
request,
context.channel(),
request.headers()
)
}
else -> {
try {
sendResource(
resourceName, urlDecoder.path().substringBeforeLast("/"),
request, context.channel(), request.headers()
)
}
catch (e: Throwable) {
HttpResponseStatus.NOT_FOUND.send(context.channel(), request, e.message)
}
}
}
val resName: String = urlDecoder.path().substring(request.uri().lastIndexOf("/") + 1)
if (StringUtil.isNotEmpty(resName)) {
try {
sendResource(resName, request, context.channel(), request.headers())
return true
}
catch (e: Exception) {
HttpResponseStatus.NOT_FOUND.send(context.channel(), request)
}
}
return false
return true
}
}
@@ -1,4 +1,4 @@
// Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
// Copyright 2000-2023 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.jetbrains.builtInHelp
import io.netty.channel.ChannelHandlerContext
@@ -6,8 +6,6 @@ import io.netty.handler.codec.http.FullHttpRequest
import io.netty.handler.codec.http.QueryStringDecoder
abstract class HelpProcessingRequestBase : HelpRequestHandlerBase() {
abstract override val prefix: String
override fun process(urlDecoder: QueryStringDecoder, request: FullHttpRequest, context: ChannelHandlerContext): Boolean {
val num: String? = urlDecoder.parameters()["num"]?.get(0)
@@ -15,7 +13,6 @@ abstract class HelpProcessingRequestBase : HelpRequestHandlerBase() {
val dataToSend = getProcessedData(urlDecoder.parameters()["q"]?.get(0)!!,
if (num != null) Integer.valueOf(num) else 100).toByteArray(Charsets.UTF_8)
sendData(dataToSend, "data.json", request, context.channel(), request.headers())
return true
}
@@ -1,13 +1,14 @@
// Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.jetbrains.builtInHelp
import com.intellij.util.ResourceUtil
import io.netty.channel.Channel
import io.netty.handler.codec.http.FullHttpRequest
import io.netty.handler.codec.http.HttpHeaders
import io.netty.handler.codec.http.HttpRequest
import org.apache.commons.compress.utils.IOUtils
import org.jetbrains.ide.HttpRequestHandler
import org.apache.commons.compress.utils.FileNameUtils
import org.jetbrains.annotations.NonNls
import org.jetbrains.ide.HttpRequestHandler
/**
* Created by Egor.Malyshev on 7/13/2017.
@@ -17,12 +18,33 @@ abstract class HelpRequestHandlerBase : HttpRequestHandler() {
open val prefix: String = "/help/"
override fun isAccessible(request: HttpRequest): Boolean {
return super.isAccessible(request) && request.uri().startsWith(prefix)
return super.isAccessible(request) && request.uri().contains(prefix)
}
protected fun sendResource(resourceName: String, request: FullHttpRequest, channel: Channel, extraHeaders: HttpHeaders): Boolean {
return sendData(IOUtils.toByteArray(HelpRequestHandlerBase::class.java.getResourceAsStream(
(if (request.uri().contains("/img/")) "/images/" else "/topics/") + resourceName) ?: throw Exception("$resourceName not found")),
resourceName, request, channel, extraHeaders)
protected fun sendResource(
resourceName: String,
resourceLocation: String,
request: FullHttpRequest,
channel: Channel,
extraHeaders: HttpHeaders
): Boolean {
val isImage = resourceLocation.contains("/img/")
val retrieveName = when (isImage) {
true -> {
val baseName = FileNameUtils.getBaseName(resourceName)
"""${baseName.substringBeforeLast("_dark")}.${FileNameUtils.getExtension(resourceName)}"""
}
else -> resourceName
}
val resStream = ResourceUtil.getResourceAsStream(
HelpRequestHandlerBase::class.java.classLoader,
if (isImage) "images" else "topics", retrieveName
)
return sendData(
resStream?.readAllBytes() ?: throw Exception("$resourceName not found in $resourceLocation via ${request.uri()}"), resourceName,
request, channel,
extraHeaders
)
}
}
@@ -2,14 +2,26 @@
package com.jetbrains.builtInHelp
import com.jetbrains.builtInHelp.search.HelpSearch
import io.netty.channel.ChannelHandlerContext
import io.netty.handler.codec.http.FullHttpRequest
import io.netty.handler.codec.http.QueryStringDecoder
import org.jetbrains.annotations.NonNls
@Suppress("unused")
class HelpSearchRequestHandler : HelpProcessingRequestBase() {
class HelpSearchRequestHandler : HelpRequestHandlerBase() {
@NonNls
override val prefix: String = "/search/"
override fun process(urlDecoder: QueryStringDecoder, request: FullHttpRequest, context: ChannelHandlerContext): Boolean {
override fun getProcessedData(query: String, maxHits: Int): String {
return HelpSearch.search(query, maxHits)
val query = urlDecoder.parameters()["query"]?.get(0)
val maxHitsParam = urlDecoder.parameters()["maxHits"]?.get(0)
sendData(HelpSearch.search(query, if (maxHitsParam != null) Integer.parseInt(maxHitsParam) else 100).encodeToByteArray(),
"data.json",
request,
context.channel(),
request.headers())
return true
}
}
@@ -1,73 +0,0 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.jetbrains.builtInHelp.search
import com.google.gson.Gson
import com.intellij.openapi.diagnostic.Logger
import org.apache.commons.compress.utils.IOUtils
import org.apache.lucene.analysis.standard.StandardAnalyzer
import org.apache.lucene.index.DirectoryReader
import org.apache.lucene.search.spell.LuceneDictionary
import org.apache.lucene.search.suggest.analyzing.BlendedInfixSuggester
import org.apache.lucene.store.FSDirectory
import org.jetbrains.annotations.NotNull
import java.io.FileOutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import org.jetbrains.annotations.NonNls
class HelpComplete {
companion object {
private val resources = arrayOf("_0.cfe", "_0.cfs", "_0.si", "segments_1")
@NonNls
val PREFIX = "/search/"
@NonNls
val NOT_FOUND = "[]"
private val analyzer: StandardAnalyzer = StandardAnalyzer()
@NotNull
fun complete(query: String, maxHits: Int): String {
val indexDir: Path? = Files.createTempDirectory("search-index")
var indexDirectory: FSDirectory? = null
var reader: DirectoryReader? = null
var suggester: BlendedInfixSuggester? = null
if (indexDir != null)
try {
for (resourceName in resources) {
val input = HelpSearch::class.java.getResourceAsStream(
PREFIX + resourceName)
val fos: FileOutputStream = FileOutputStream(Paths.get(indexDir.toAbsolutePath().toString(), resourceName).toFile())
IOUtils.copy(input, fos)
fos.flush()
fos.close()
input.close()
}
indexDirectory = FSDirectory.open(indexDir)
reader = DirectoryReader.open(indexDirectory)
suggester = BlendedInfixSuggester(indexDirectory, analyzer)
suggester.build(LuceneDictionary(reader, "contents"))
val completionResults = suggester.lookup(query, maxHits, false, true)
return Gson().toJson(completionResults)
}
catch (e: Exception) {
Logger.getInstance(HelpComplete::class.java).error("Error searching help for $query", e)
}
finally {
suggester?.close()
indexDirectory?.close()
reader?.close()
for (f in indexDir.toFile().listFiles()) f.delete()
Files.delete(indexDir)
}
return NOT_FOUND
}
}
}
@@ -1,53 +0,0 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.jetbrains.builtInHelp.search
import org.apache.commons.compress.utils.IOUtils
import org.apache.lucene.analysis.standard.StandardAnalyzer
import org.apache.lucene.store.FSDirectory
import org.jetbrains.annotations.NotNull
import java.io.FileOutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import org.jetbrains.annotations.NonNls
abstract class HelpIndexProcessor {
companion object {
val resources = arrayOf("_0.cfe", "_0.cfs", "_0.si", "segments_1")
@NonNls
val STORAGE_PREFIX = "/search/"
@NonNls
val EMPTY_RESULT = "[]"
}
val analyzer: StandardAnalyzer = StandardAnalyzer()
fun deployIndex(): FSDirectory? {
val indexDir: Path? = Files.createTempDirectory("search-index")
var result: FSDirectory? = null
if (indexDir != null) {
for (resourceName in resources) {
val input = HelpSearch::class.java.getResourceAsStream(
STORAGE_PREFIX + resourceName)
val fos: FileOutputStream = FileOutputStream(Paths.get(indexDir.toAbsolutePath().toString(), resourceName).toFile())
IOUtils.copy(input, fos)
fos.flush()
fos.close()
input.close()
}
result = FSDirectory.open(indexDir)
}
return result
}
fun releaseIndex(indexDirectory: FSDirectory) {
val path = indexDirectory.directory
for (f in path.toFile().listFiles()) f.delete()
Files.delete(path)
}
@NotNull
abstract fun process(query: String, maxResults: Int): String
}
@@ -1,95 +1,153 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
// Copyright 2000-2022 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.jetbrains.builtInHelp.search
import com.google.gson.Gson
import com.intellij.openapi.application.ApplicationInfo
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.intellij.openapi.diagnostic.Logger
import com.intellij.util.ResourceUtil
import org.apache.commons.compress.utils.IOUtils
import org.apache.lucene.analysis.standard.StandardAnalyzer
import org.apache.lucene.index.DirectoryReader
import org.apache.lucene.queryparser.classic.QueryParser
import org.apache.lucene.queryparser.simple.SimpleQueryParser
import org.apache.lucene.search.IndexSearcher
import org.apache.lucene.search.Query
import org.apache.lucene.search.TopScoreDocCollector
import org.apache.lucene.search.highlight.Highlighter
import org.apache.lucene.search.highlight.QueryScorer
import org.apache.lucene.search.highlight.Scorer
import org.apache.lucene.store.FSDirectory
import org.jetbrains.annotations.NotNull
import org.jetbrains.annotations.NonNls
import org.jetbrains.annotations.NotNull
import java.io.FileOutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
class HelpSearch {
companion object {
private val resources = arrayOf("_0.cfe", "_0.cfs", "_0.si", "segments_1")
@NonNls
val PREFIX = "/search/"
private val NOT_FOUND = "[]"
private const val NOT_FOUND = "[]"
private val analyzer: StandardAnalyzer = StandardAnalyzer()
@NotNull
fun search(query: String, maxHits: Int): String {
fun search(query: String?, maxHits: Int): String {
val indexDir: Path? = Files.createTempDirectory("search-index")
var indexDirectory: FSDirectory? = null
var reader: DirectoryReader? = null
if (indexDir != null)
try {
if (query != null) {
val indexDir: Path? = Files.createTempDirectory("search-index")
var indexDirectory: FSDirectory? = null
var reader: DirectoryReader? = null
if (indexDir != null)
try {
for (resourceName in resources) {
val input = HelpSearch::class.java.getResourceAsStream(
PREFIX + resourceName)
val fos = FileOutputStream(Paths.get(indexDir.toAbsolutePath().toString(), resourceName).toFile())
IOUtils.copy(input, fos)
fos.flush()
fos.close()
input.close()
for (resourceName in resources) {
val input = ResourceUtil.getResourceAsStream(HelpSearch::class.java.classLoader,
"search",
resourceName)
val fos =
FileOutputStream(Paths.get(indexDir.toAbsolutePath().toString(), resourceName).toFile())
IOUtils.copy(input, fos)
fos.flush()
fos.close()
input?.close()
}
indexDirectory = FSDirectory.open(indexDir)
reader = DirectoryReader.open(indexDirectory)
val searcher = IndexSearcher(reader)
val collector: TopScoreDocCollector = TopScoreDocCollector.create(maxHits, maxHits)
val q = SimpleQueryParser(analyzer, mapOf(Pair("contents", 1.0F), Pair("title", 1.5F))).parse(query)
searcher.search(q, collector)
val hits = collector.topDocs().scoreDocs
val scorer: Scorer = QueryScorer(q)
val highlighter = Highlighter(scorer)
val results = ArrayList<HelpSearchResult>()
for (i in hits.indices) {
val doc = searcher.doc(hits[i].doc)
val contentValue = buildString {
append(highlighter.getBestFragment(
analyzer, "contents", doc.get("contents")
))
append("...")
}
results.add(
HelpSearchResult(
doc.get("filename"),
doc.get("title"),
"",
doc.get("title"),
i.toString(),
HelpSearchResult.SnippetResult(
HelpSearchResult.SnippetResult.Content(
contentValue, "full"
)
),
HelpSearchResult.HighlightedResult(
HelpSearchResult.HelpSearchResultDetails(doc.get("filename")),
HelpSearchResult.HelpSearchResultDetails(doc.get("title")),
HelpSearchResult.HelpSearchResultDetails(contentValue),
HelpSearchResult.HelpSearchResultDetails(doc.get("title")),
HelpSearchResult.HelpSearchResultDetails(doc.get("title"))
)
)
)
}
val searchResults = HelpSearchResults(results)
if (searchResults.hits.isNotEmpty()) return jacksonObjectMapper().writeValueAsString(searchResults)
}
indexDirectory = FSDirectory.open(indexDir)
reader = DirectoryReader.open(indexDirectory)
ApplicationInfo.getInstance()
val searcher = IndexSearcher(reader)
val collector: TopScoreDocCollector = TopScoreDocCollector.create(maxHits, maxHits)
val q: Query = QueryParser("contents", analyzer).parse(query)
searcher.search(q, collector)
val hits = collector.topDocs().scoreDocs
val scorer: Scorer = QueryScorer(q)
val highlighter = Highlighter(scorer)
val results = ArrayList<HelpSearchResult>()
for (i in hits.indices) {
val doc = searcher.doc(hits[i].doc)
results.add(
HelpSearchResult(i, doc.get("filename"), highlighter.getBestFragment(
analyzer, "contents", doc.get("contents")) + "...",
doc.get("title"), listOf("webhelp")))
catch (e: Throwable) {
Logger.getInstance(HelpSearch::class.java).info("Error searching help for $query", e)
}
val searchResults = HelpSearchResults(results)
return if (searchResults.results.isEmpty()) NOT_FOUND else Gson().toJson(searchResults)
}
catch (e: Exception) {
Logger.getInstance(HelpSearch::class.java).error("Error searching help for $query", e)
}
finally {
indexDirectory?.close()
reader?.close()
val tempFiles = indexDir.toFile().listFiles()
tempFiles?.forEach { it.delete() }
Files.delete(indexDir)
}
finally {
indexDirectory?.close()
reader?.close()
val tempFiles = indexDir.toFile().listFiles()
tempFiles?.forEach { it.delete() }
Files.delete(indexDir)
}
}
return NOT_FOUND
}
}
}
}
data class HelpSearchResult(
val url: String,
val pageTitle: String,
val breadcrumbs: String,
val mainTitle: String,
val objectID: String,
val _snippetResult: SnippetResult,
val _highlightResult: HighlightedResult
) {
data class SnippetResult(val content: Content) {
data class Content(
val value: String,
val matchLevel: String
)
}
data class HelpSearchResultDetails(
val value: String,
val matchLevel: String = "full",
val fullyHighlighted: Boolean = true,
val matchedWords: List<String> = Collections.emptyList()
)
data class HighlightedResult(
val url: HelpSearchResultDetails,
val pageTitle: HelpSearchResultDetails,
val metaDescription: HelpSearchResultDetails,
val mainTitle: HelpSearchResultDetails,
val headings: HelpSearchResultDetails
)
}
data class HelpSearchResults(var hits: List<HelpSearchResult>)
@@ -1,5 +0,0 @@
package com.jetbrains.builtInHelp.search
data class HelpSearchResult(var number: Int, var url: String, var snippet: String, var title: String, var categories: List<String>)
@@ -1,4 +0,0 @@
// Copyright 2000-2019 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
package com.jetbrains.builtInHelp.search
data class HelpSearchResults(var results: List<HelpSearchResult>)