Flatten packages
Remove modules prefix
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package be.simplenotes.search
|
||||
|
||||
internal const val uuidField = "uuid"
|
||||
internal const val titleField = "title"
|
||||
internal const val tagsField = "tags"
|
||||
internal const val contentField = "content"
|
||||
internal const val updatedAtField = "updatedAt"
|
||||
@@ -0,0 +1,29 @@
|
||||
package be.simplenotes.search
|
||||
|
||||
import be.simplenotes.types.PersistedNote
|
||||
import be.simplenotes.types.PersistedNoteMetadata
|
||||
import org.apache.lucene.document.Document
|
||||
import org.apache.lucene.document.Field
|
||||
import org.apache.lucene.document.StringField
|
||||
import org.apache.lucene.document.TextField
|
||||
|
||||
internal fun PersistedNote.toDocument(): Document {
|
||||
val note = this
|
||||
return Document().apply {
|
||||
// non searchable fields
|
||||
add(StringField(uuidField, UuidFieldConverter.toDoc(note.uuid), Field.Store.YES))
|
||||
add(StringField(updatedAtField, LocalDateTimeFieldConverter.toDoc(note.updatedAt), Field.Store.YES))
|
||||
|
||||
// searchable fields
|
||||
add(TextField(titleField, note.meta.title, Field.Store.YES))
|
||||
add(TextField(tagsField, TagsFieldConverter.toDoc(note.meta.tags), Field.Store.YES))
|
||||
add(TextField(contentField, note.markdown, Field.Store.YES))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Document.toNoteMeta() = PersistedNoteMetadata(
|
||||
title = get(titleField),
|
||||
uuid = UuidFieldConverter.fromDoc(get(uuidField)),
|
||||
updatedAt = LocalDateTimeFieldConverter.fromDoc(get(updatedAtField)),
|
||||
tags = TagsFieldConverter.fromDoc(get(tagsField))
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
package be.simplenotes.search
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
|
||||
internal interface FieldConverter<T> {
|
||||
fun toDoc(value: T): String
|
||||
fun fromDoc(value: String): T
|
||||
}
|
||||
|
||||
internal object LocalDateTimeFieldConverter : FieldConverter<LocalDateTime> {
|
||||
private val formatter = DateTimeFormatter.ISO_DATE_TIME
|
||||
override fun toDoc(value: LocalDateTime): String = formatter.format(value)
|
||||
override fun fromDoc(value: String): LocalDateTime = LocalDateTime.parse(value, formatter)
|
||||
}
|
||||
|
||||
internal object UuidFieldConverter : FieldConverter<UUID> {
|
||||
override fun toDoc(value: UUID): String = value.toString()
|
||||
override fun fromDoc(value: String): UUID = UUID.fromString(value)
|
||||
}
|
||||
|
||||
internal object TagsFieldConverter : FieldConverter<List<String>> {
|
||||
override fun toDoc(value: List<String>): String = value.joinToString(" ")
|
||||
override fun fromDoc(value: String): List<String> = value.split(" ").filter(String::isNotEmpty)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package be.simplenotes.search
|
||||
|
||||
import org.apache.lucene.document.Document
|
||||
import org.apache.lucene.index.Term
|
||||
import org.apache.lucene.search.BooleanClause
|
||||
import org.apache.lucene.search.BooleanQuery
|
||||
import org.apache.lucene.search.FuzzyQuery
|
||||
import org.apache.lucene.search.IndexSearcher
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
private val logger = LoggerFactory.getLogger("be.simplenotes.search.dsl")
|
||||
|
||||
internal fun IndexSearcher.query(receiver: LuceneDsl.() -> Unit): List<Document> {
|
||||
val indexSearcher = this
|
||||
val builder = BooleanQuery.Builder()
|
||||
val dsl = LuceneDsl()
|
||||
dsl.apply { this.receiver() }
|
||||
dsl.clauses.forEach { (field, query) ->
|
||||
query?.let {
|
||||
builder.add(BooleanClause(FuzzyQuery(Term(field, query)), BooleanClause.Occur.SHOULD))
|
||||
}
|
||||
}
|
||||
val query = builder.build()
|
||||
val topDocs = indexSearcher.search(query, dsl.count)
|
||||
logger.debug("Searching: `$query` results: ${topDocs.totalHits.value}")
|
||||
return topDocs.scoreDocs.map { indexSearcher.doc(it.doc) }
|
||||
}
|
||||
|
||||
internal class LuceneDsl {
|
||||
val clauses = mutableListOf<BooleanExpression>()
|
||||
var count: Int = 10
|
||||
|
||||
fun addBooleanClause(booleanDsl: BooleanExpression) {
|
||||
clauses.add(booleanDsl)
|
||||
}
|
||||
|
||||
infix fun List<String>.anyMatch(query: String?) {
|
||||
map { BooleanExpression(it, query) }.forEach {
|
||||
addBooleanClause(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun LuceneDsl.or(booleanExpression: () -> BooleanExpression) {
|
||||
addBooleanClause(booleanExpression())
|
||||
}
|
||||
|
||||
internal infix fun String.eq(query: String?) = BooleanExpression(this, query)
|
||||
|
||||
internal data class BooleanExpression(val term: String, val query: String?)
|
||||
@@ -0,0 +1,108 @@
|
||||
package be.simplenotes.search
|
||||
|
||||
import be.simplenotes.types.PersistedNote
|
||||
import org.apache.lucene.analysis.standard.StandardAnalyzer
|
||||
import org.apache.lucene.document.Document
|
||||
import org.apache.lucene.index.*
|
||||
import org.apache.lucene.search.IndexSearcher
|
||||
import org.apache.lucene.search.TermQuery
|
||||
import org.apache.lucene.store.Directory
|
||||
import org.apache.lucene.store.FSDirectory
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.io.File
|
||||
import java.nio.file.Path
|
||||
import java.util.*
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
internal class NoteSearcherImpl(
|
||||
@Named("search-index")
|
||||
basePath: Path,
|
||||
) : NoteSearcher {
|
||||
|
||||
constructor() : this(Path.of("/tmp", "lucene"))
|
||||
|
||||
private val baseFile = basePath.toFile()
|
||||
|
||||
private val logger = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
// region utils
|
||||
private fun getDirectory(userId: Int): Directory {
|
||||
val index = File(baseFile, userId.toString()).toPath()
|
||||
return FSDirectory.open(index)
|
||||
}
|
||||
|
||||
private fun indexSearcher(userId: Int): IndexSearcher {
|
||||
val directory = getDirectory(userId)
|
||||
val reader: IndexReader = DirectoryReader.open(directory)
|
||||
return IndexSearcher(reader)
|
||||
}
|
||||
|
||||
private fun writer(userId: Int): IndexWriter {
|
||||
val dir = getDirectory(userId)
|
||||
val config = IndexWriterConfig(StandardAnalyzer())
|
||||
return IndexWriter(dir, config)
|
||||
}
|
||||
// endregion
|
||||
|
||||
override fun indexNote(userId: Int, note: PersistedNote) {
|
||||
logger.debug("Indexing note ${note.uuid} for user $userId")
|
||||
|
||||
val doc = note.toDocument()
|
||||
|
||||
with(writer(userId)) {
|
||||
addDocument(doc)
|
||||
commit()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun indexNotes(userId: Int, notes: List<PersistedNote>) {
|
||||
logger.debug("Indexing notes for user $userId")
|
||||
|
||||
val docs = notes.map { it.toDocument() }
|
||||
|
||||
with(writer(userId)) {
|
||||
addDocuments(docs)
|
||||
commit()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun deleteIndex(userId: Int, uuid: UUID) {
|
||||
logger.debug("Deleting index $uuid for user $userId")
|
||||
|
||||
with(writer(userId)) {
|
||||
deleteDocuments(TermQuery(Term(uuidField, UuidFieldConverter.toDoc(uuid))))
|
||||
commit()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateIndex(userId: Int, note: PersistedNote) {
|
||||
logger.debug("Updating note ${note.uuid} for user $userId")
|
||||
deleteIndex(userId, note.uuid)
|
||||
indexNote(userId, note)
|
||||
}
|
||||
|
||||
override fun search(userId: Int, terms: SearchTerms) = try {
|
||||
indexSearcher(userId).query {
|
||||
or { titleField eq terms.title }
|
||||
or { tagsField eq terms.tag }
|
||||
or { contentField eq terms.content }
|
||||
listOf(titleField, tagsField, contentField) anyMatch terms.all
|
||||
}.map(Document::toNoteMeta)
|
||||
} catch (e: IndexNotFoundException) {
|
||||
logger.warn("Index not found for user $userId")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
override fun dropIndex(userId: Int) {
|
||||
File(baseFile, userId.toString()).deleteRecursively()
|
||||
}
|
||||
|
||||
override fun dropAll() {
|
||||
baseFile.deleteRecursively()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package be.simplenotes.search
|
||||
|
||||
import io.micronaut.context.annotation.Factory
|
||||
import io.micronaut.context.annotation.Prototype
|
||||
import java.nio.file.Path
|
||||
import javax.inject.Named
|
||||
|
||||
@Factory
|
||||
class SearchModule {
|
||||
|
||||
@Named("search-index")
|
||||
@Prototype
|
||||
internal fun luceneIndex() = Path.of(".lucene")
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package be.simplenotes.search
|
||||
|
||||
import be.simplenotes.types.PersistedNote
|
||||
import be.simplenotes.types.PersistedNoteMetadata
|
||||
import java.util.*
|
||||
|
||||
data class SearchTerms(val title: String?, val tag: String?, val content: String?, val all: String?)
|
||||
|
||||
interface NoteSearcher {
|
||||
fun indexNote(userId: Int, note: PersistedNote)
|
||||
fun indexNotes(userId: Int, notes: List<PersistedNote>)
|
||||
fun deleteIndex(userId: Int, uuid: UUID)
|
||||
fun updateIndex(userId: Int, note: PersistedNote)
|
||||
fun search(userId: Int, terms: SearchTerms): List<PersistedNoteMetadata>
|
||||
fun dropIndex(userId: Int)
|
||||
fun dropAll()
|
||||
}
|
||||
Reference in New Issue
Block a user