diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7c3a5656f4..9ea278697c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -119,6 +119,11 @@ android:configChanges="keyboardHidden|orientation|screenSize|locale" android:label="@string/search" /> + = cleanSearchString.split(" ") + for (it in wordArray) { + var decoratedWord = it + if (includeAllEndings) decoratedWord += "* " + if (isStrongsSearch) decoratedWord = "strong:$strongs$decoratedWord " + if (fuzzySearchAccuracy != null) { + val fuzzySearchAccuracyAdjusted = if (fuzzySearchAccuracy.equals(1.0)) 0.99 else fuzzySearchAccuracy + decoratedWord += "~%.2f ".format(fuzzySearchAccuracyAdjusted) + } + newSearchString += decoratedWord + } + cleanSearchString = newSearchString.trim() + } + + if (proximityWords != null) { + cleanSearchString = "\"$cleanSearchString\"~$proximityWords" + } // add search type (all/any/phrase) to search string - var decorated: String = searchType.decorate(cleanSearchString) + var decorated = searchType.decorate(cleanSearchString) originalSearchString = decorated // add bible section limitation to search text @@ -139,6 +167,43 @@ class SearchControl @Inject constructor( return searchResults } + fun getSearchResultVerseText(key: Key?): String { + // There is similar functionality in BookmarkControl + // This is much slower than 'getSearchResultVerseElement'. Why? In the old version this was VERY fast. + var verseText = "" + try { + val doc = windowControl.activeWindowPageManager.currentPage.currentDocument + val cat = doc!!.bookCategory + verseText = if (cat == BookCategory.BIBLE || cat == BookCategory.COMMENTARY) { + getPlainText(doc, key) + } else { + val bible = windowControl.activeWindowPageManager.currentBible.currentDocument!! + getPlainText(bible, key) + } + verseText = limitTextLength(verseText)!! + } catch (e: Exception) { + Log.e(TAG, "Error getting verse text", e) + } + return verseText + } + + fun getSearchResultVerseElement(key: Key?): Element { + // There is similar functionality in BookmarkControl + var xmlVerse:Element? = null + try { + val doc = windowControl.activeWindowPageManager.currentPage.currentDocument + val cat = doc!!.bookCategory + xmlVerse = if (cat == BookCategory.BIBLE || cat == BookCategory.COMMENTARY) { + readOsisFragment(doc, key) + } else { + val bible = windowControl.activeWindowPageManager.currentBible.currentDocument!! + readOsisFragment(bible, key) + } + } catch (e: Exception) { + Log.e(TAG, "Error getting verse text", e) + } + return xmlVerse!! + } /** double spaces, :, and leading or trailing space cause lucene errors */ private fun cleanSearchString(search: String): String { @@ -212,7 +277,7 @@ class SearchControl @Inject constructor( const val TARGET_DOCUMENT = "TargetDocument" private const val STRONG_COLON_STRING = LuceneIndex.FIELD_STRONG + ":" private const val STRONG_COLON_STRING_PLACE_HOLDER = LuceneIndex.FIELD_STRONG + "COLON" - const val MAX_SEARCH_RESULTS = 1000 + const val MAX_SEARCH_RESULTS = 10000 private const val TAG = "SearchControl" } } diff --git a/app/src/main/java/net/bible/android/view/activity/ActivityComponent.kt b/app/src/main/java/net/bible/android/view/activity/ActivityComponent.kt index 2c39ff31bb..0e1207f80e 100644 --- a/app/src/main/java/net/bible/android/view/activity/ActivityComponent.kt +++ b/app/src/main/java/net/bible/android/view/activity/ActivityComponent.kt @@ -41,6 +41,7 @@ import net.bible.android.view.activity.page.screen.DocumentViewManager import net.bible.android.view.activity.readingplan.DailyReading import net.bible.android.view.activity.readingplan.DailyReadingList import net.bible.android.view.activity.readingplan.ReadingPlanSelectorList +import net.bible.android.view.activity.search.MySearchResults import net.bible.android.view.activity.search.Search import net.bible.android.view.activity.search.SearchIndex import net.bible.android.view.activity.search.SearchIndexProgressStatus @@ -102,6 +103,7 @@ interface ActivityComponent { fun inject(w: SpeakTransportWidget) fun inject(search: Search) fun inject(searchResults: SearchResults) + fun inject(mySearchResults: MySearchResults) fun inject(history: History) // Services diff --git a/app/src/main/java/net/bible/android/view/activity/navigation/GridChoosePassageBook.kt b/app/src/main/java/net/bible/android/view/activity/navigation/GridChoosePassageBook.kt index ed347d650d..26b001317f 100644 --- a/app/src/main/java/net/bible/android/view/activity/navigation/GridChoosePassageBook.kt +++ b/app/src/main/java/net/bible/android/view/activity/navigation/GridChoosePassageBook.kt @@ -320,10 +320,10 @@ class GridChoosePassageBook : CustomTitlebarActivityBase(R.menu.choose_passage_b private val HISTORY_COLOR = Color.rgb(0xFE, 0xCC, 0x9B) private val WISDOM_COLOR = Color.rgb(0x99, 0xFF, 0x99) private val MAJOR_PROPHETS_COLOR = Color.rgb(0xFF, 0x99, 0xFF) - private val MINOR_PROPHETS_COLOR = Color.rgb(0xFF, 0xFE, 0xCD) + private val MINOR_PROPHETS_COLOR = Color.rgb(0xE6, 0xE5, 0xB8) private val GOSPEL_COLOR = Color.rgb(0xFF, 0x97, 0x03) private val ACTS_COLOR = Color.rgb(0x00, 0x99, 0xFF) - private val PAULINE_COLOR = Color.rgb(0xFF, 0xFF, 0x31) + private val PAULINE_COLOR = Color.rgb(0xF5, 0xf5, 0x21) private val GENERAL_EPISTLES_COLOR = Color.rgb(0x67, 0xCC, 0x66) // changed 99 to CC to make a little clearer on dark background private val REVELATION_COLOR = Color.rgb(0xFE, 0x33, 0xFF) private val OTHER_COLOR = ACTS_COLOR diff --git a/app/src/main/java/net/bible/android/view/activity/search/MySearchResults.kt b/app/src/main/java/net/bible/android/view/activity/search/MySearchResults.kt new file mode 100644 index 0000000000..003deb3d0c --- /dev/null +++ b/app/src/main/java/net/bible/android/view/activity/search/MySearchResults.kt @@ -0,0 +1,442 @@ +package net.bible.android.view.activity.search + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.Typeface +import android.os.Bundle +import android.os.Parcel +import android.os.Parcelable +import android.text.SpannableString +import android.text.style.StyleSpan +import android.util.Log +import android.view.MenuItem +import android.view.View +import android.view.Window +import android.widget.Toast +import androidx.core.text.toHtml +import com.google.android.material.tabs.TabLayout +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter +import androidx.viewpager.widget.ViewPager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.bible.android.activity.databinding.SearchResultsStatisticsBinding +import java.util.ArrayList +import net.bible.android.activity.R +import net.bible.android.control.event.ABEventBus +import net.bible.android.control.event.ToastEvent +import net.bible.android.control.link.LinkControl +import net.bible.android.control.navigation.NavigationControl +import net.bible.android.control.page.window.WindowControl +import net.bible.android.control.search.SearchControl +//import net.bible.android.view.activity.search.SearchResultsDto +import net.bible.android.view.activity.base.CustomTitlebarActivityBase +import net.bible.android.view.activity.base.Dialogs +import net.bible.android.view.activity.navigation.GridChoosePassageBook +import net.bible.android.view.activity.search.searchresultsactionbar.SearchResultsActionBarManager +import org.apache.commons.lang3.StringUtils +import org.crosswire.jsword.passage.Key +import org.crosswire.jsword.passage.Verse +import javax.inject.Inject +import org.crosswire.jsword.versification.BibleBook +import org.crosswire.jsword.versification.Versification +import net.bible.service.common.CommonUtils +import net.bible.service.common.CommonUtils.resources + + + +private var TAB_TITLES = arrayOf( + resources.getString(R.string.add_bookmark_whole_verse1), + resources.getString(R.string.by_book), + resources.getString(R.string.by_word) +) +var mCurrentlyDisplayedSearchResults: List = ArrayList() // I tried to make this a non-global but i had the same problem as before where the values in the variable was one behind the search. I don't know why!! +const val verseTabPosition = 0 +private const val bookTabPosition = 1 +private const val wordTabPosition = 2 + +class BookStat(val book: String, var count: Int, + val bookInitials: String, + val bookOrdinal:Int, + val listIndex:Int, + val color: Int) { + override fun toString(): String = "$book: $count" +} +class WordStat(var word: String, + var verseIndexes: IntArray, + val originalWord: String, + var tag: Boolean = false) { + override fun toString(): String = "$originalWord: ${verseIndexes.count()}" +} + +private var wordHits: ArrayList>> = arrayListOf() + +class SearchResultsData : Parcelable { + @JvmField var id: Int? // Holds the original index in the results. Used for selection and filtering. + @JvmField var osisKey: String? + @JvmField var reference: String? + @JvmField var translation: String? + @JvmField var verse: String? + @JvmField var verseHtml: String? // I would prefer to pass a spannable string but i dont know how to make it parcelable + + /* What I really want to do is make the 'Key' parcelable but I don't know how to do that. So instead I have to send the properties I need and get the key later on */ + + constructor(id: Int?, osisKey: String?, reference: String?, translation: String?, verseStr: String?, verseHtml: String?) { + this.id = id; + this.osisKey = osisKey + this.reference = reference + this.translation = translation + this.verse = verseStr + this.verseHtml = verseHtml + } + + private constructor(p: Parcel) { + id = p.readInt() + osisKey = p.readString() + reference = p.readString() + translation = p.readString() + verse = p.readString() + verseHtml = p.readString() + } + + override fun describeContents(): Int { + return this.hashCode() + } + + override fun writeToParcel(dest: Parcel, flags: Int) { + dest.writeInt(id!!) + dest.writeString(osisKey) + dest.writeString(reference) + dest.writeString(translation) + dest.writeString(verse) + dest.writeString(verseHtml) + + } + + companion object CREATOR: Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): SearchResultsData { + return SearchResultsData(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} + +class MySearchResults : CustomTitlebarActivityBase() { + private lateinit var binding: SearchResultsStatisticsBinding + private var mSearchResultsHolder: SearchResultsDto? = null + private lateinit var sectionsPagerAdapter: SearchResultsPagerAdapter + private val mSearchResultsArray = ArrayList() + private val bookStatistics = mutableListOf() + private val wordStatistics = mutableListOf() + private val keyWordStatistics = mutableListOf() + + @Inject lateinit var navigationControl: NavigationControl + private var isScriptureResultsCurrentlyShown = true + @Inject lateinit var searchResultsActionBarManager: SearchResultsActionBarManager + @Inject lateinit var searchControl: SearchControl + @Inject lateinit var linkControl: LinkControl + @Inject lateinit var windowControl: WindowControl + + private val versification get() = navigationControl.versification + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + @SuppressLint("MissingSuperCall") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.i(TAG, "Displaying Search results view") + + binding = SearchResultsStatisticsBinding.inflate(layoutInflater) + setContentView(binding.root) + + buildActivityComponent().inject(this) + + val bundle = Bundle() + bundle.putString("edttext", "From Activity") + + sectionsPagerAdapter = SearchResultsPagerAdapter(this, + supportFragmentManager, searchControl, windowControl, intent, + mSearchResultsArray, bookStatistics, wordStatistics, keyWordStatistics + ) + + binding.viewPager.run { + adapter = sectionsPagerAdapter + // The progressbar on the 3rd tab goes to zero when the view is lost. + // So just keep it in memory and all is fine. It is not a big view so i think it is ok. + offscreenPageLimit = 2 + binding.tabs.setupWithViewPager(this) + } + + searchResultsActionBarManager.registerScriptureToggleClickListener(scriptureToggleClickListener) + setActionBarManager(searchResultsActionBarManager) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + isScriptureResultsCurrentlyShown = searchControl.isCurrentlyShowingScripture + + GlobalScope.launch { + prepareResults() + } + } + + private suspend fun prepareResults() = withContext(Dispatchers.Main){ + binding.loadingIndicator.visibility = View.VISIBLE + binding.empty.visibility = View.GONE + if (fetchSearchResults()) { // initialise adapters before result population - easier when updating due to later Scripture toggle + populateViewResultsAdapter() + } + binding.loadingIndicator.visibility = View.GONE +// if(listAdapter?.isEmpty == true) { +// binding.empty.visibility = View.VISIBLE +// } + } + + private suspend fun fetchSearchResults(): Boolean = withContext(Dispatchers.IO) { + Log.i(TAG, "Preparing search results") + var isOk: Boolean + try { // get search string - passed in using extras so extras cannot be null + val extras = intent.extras!! + val searchText = extras.getString(SearchControl.SEARCH_TEXT) + var searchDocument = extras.getString(SearchControl.SEARCH_DOCUMENT) + if (StringUtils.isEmpty(searchDocument)) { + searchDocument = windowControl.activeWindowPageManager.currentPage.currentDocument!!.initials + } + mSearchResultsHolder = searchControl.getSearchResults(searchDocument, searchText) + // tell user how many results were returned + val msg:String = if (mCurrentlyDisplayedSearchResults.size >= SearchControl.MAX_SEARCH_RESULTS) { + getString(R.string.search_showing_first, SearchControl.MAX_SEARCH_RESULTS) + } else { + getString(R.string.search_result_count, mSearchResultsHolder!!.size) + } + withContext(Dispatchers.Main) { + ABEventBus.post(ToastEvent(msg)) + } + isOk = true + } catch (e: Exception) { + Log.e(TAG, "Error processing search query", e) + isOk = false + Dialogs.showErrorMsg(R.string.error_executing_search) { onBackPressed() } + } + return@withContext isOk + } + + /** + * Move search results into view Adapter + */ + + private fun populateViewResultsAdapter() { + + mCurrentlyDisplayedSearchResults = if (isScriptureResultsCurrentlyShown) { + mSearchResultsHolder!!.mainSearchResults + } else { + mSearchResultsHolder!!.otherSearchResults + } + val extras = intent.extras + val searchDocument = extras!!.getString(SearchControl.SEARCH_DOCUMENT) + + mSearchResultsArray.clear() + bookStatistics.clear() + wordStatistics.clear() + keyWordStatistics.clear() + + var listIndex = 0 + var totalWords = 0 + val searchHighlight = SearchHighlight(SearchControl.originalSearchString!!) + for (key in mCurrentlyDisplayedSearchResults) { + // Add verse to results array + + var verseTextSpannable: SpannableString? + val verseTextElement = searchControl.getSearchResultVerseElement(key) + + // Build a spannable verse highlighting the relevant words. + verseTextSpannable = searchHighlight.generateSpannableFromVerseElement(verseTextElement) + + mSearchResultsArray.add(SearchResultsData(listIndex, key.osisID.toString(), key.name,searchDocument, "text", verseTextSpannable.toHtml())) + + // Add book to the book statistics array + val mBibleBook = (key as Verse).book + val bookOrdinal = mBibleBook.ordinal + val bookNameLong = versification.getLongName(mBibleBook) // key.rootName + val bookStat = bookStatistics.firstOrNull { it.book == bookNameLong } + if (bookStat == null) { + bookStatistics.add(BookStat(bookNameLong, 1, bookNameLong, bookOrdinal, listIndex, GridChoosePassageBook.getBookColorAndGroup(bookOrdinal).Color )) + } else { + bookStatistics.first { it.book == bookNameLong }.count += 1 + } + + // Add words in this verse to word statistics array + val verseSpans: Array = verseTextSpannable.getSpans(0, verseTextSpannable.length, StyleSpan::class.java) + for (i in verseSpans.indices) { + if (verseSpans[i].style == Typeface.BOLD) { + totalWords += 1 + val start = verseTextSpannable.getSpanStart(verseSpans[i]) + val end = verseTextSpannable.getSpanEnd(verseSpans[i]) + + val wordArray = CharArray(end-start) + verseTextSpannable.getChars(start, end, wordArray, 0) + val originalWord = wordArray.joinToString("") + val word = originalWord.lowercase() + val wordStat = wordStatistics.firstOrNull { it.word == word } + if (wordStat == null) { + wordStatistics.add(WordStat(word, intArrayOf(listIndex), originalWord)) + } else { + wordStatistics.first { it.word == word }.verseIndexes += listIndex + } + } + } + listIndex += 1 + } + // Initialise keyword lists + val tmpKeyWordStatistics = mutableListOf() + val tmpWordStatistics = wordStatistics.toMutableList() +// val wordsToExclude = listOf("and","or") + +// // Cleanup original word list +// tmpWordStatistics.filter {"“" in it.originalWord }.map { +// it.word = it.originalWord.replace("“","",true) +// } + + // Exclude short words (eg 'and', 'or', 'it' etc) + tmpWordStatistics.filter{it.word.length<=3 && it.word != "God"}.map {it.tag = true} + + // Remove short single words from the beginning of a phrase + tmpWordStatistics.filter{it.word.indexOf(" ") > 0 && it.word.indexOf(" ") < 4}.map { + it.word = it.word.substring(it.word.indexOf(" ")+1).trim() + } + + // Find all single words + tmpWordStatistics.filter{ !it.tag }.sortedBy { it.originalWord.length }.map { wordStat -> + // TODO: I think longer single words that contain shorter single words are being excluded (correctly) but their VerseIndexes are not being included + // TODO: Check Greek word Thirsty G1372 + // TODO: This IF should be able to be moved into the filter above + if (" " !in wordStat.word) { + wordStat.tag = true // Don't process this entry again + if (!keyWordStatistics.any { wordStat.word.contains(it.word,true) }) { + tmpKeyWordStatistics.add(WordStat(wordStat.word, wordStat.verseIndexes, wordStat.originalWord)) + keyWordStatistics.add(WordStat(wordStat.word, wordStat.verseIndexes, wordStat.word)) + } + } + } + + // Find phrases that have a single keyword in them. Order is important to exclude words with punctuation (eg "Gather) + keyWordStatistics.sortedBy { it.originalWord.length }.map { keyWord -> + tmpWordStatistics.filter { !it.tag && it.word.contains(keyWord.word,true) }.map { multiWordStat -> + multiWordStat.tag = true // Exclude the + keyWord.verseIndexes += multiWordStat.verseIndexes + } + } + // Find phrases that are in other longer phrases + tmpWordStatistics.filter{ !it.tag }.sortedBy { it.word.length }.map { shortPhrase -> + tmpWordStatistics.filter { !it.tag && it.word != shortPhrase.word && it.word.contains(shortPhrase.word,true) } + .map { longPhrase -> + longPhrase.tag = true + shortPhrase.verseIndexes += longPhrase.verseIndexes + } + } + // Add all the phrases that have not been tagged (excludes single words, long phrases that appear in shorter phrases) + tmpWordStatistics.filter{ !it.tag }.map { + keyWordStatistics.add(it) + } + + sectionsPagerAdapter.verseListFrag.arrayAdapter.notifyDataSetChanged() + sectionsPagerAdapter.getItem(bookTabPosition) + sectionsPagerAdapter.getItem(wordTabPosition) + sectionsPagerAdapter.notifyDataSetChanged() + + val tabHost = this.findViewById(R.id.tabs) as TabLayout + tabHost.getTabAt(verseTabPosition)!!.text = CommonUtils.resources.getString(R.string.verse_count, mSearchResultsArray.count().toString()) // For some reason the value set in 'setResultsAdapter' get's cleared so I need to do it here as well. + tabHost.getTabAt(bookTabPosition)!!.text = CommonUtils.resources.getString(R.string.book_count, bookStatistics.count().toString()) + tabHost.getTabAt(wordTabPosition)!!.text = CommonUtils.resources.getString(R.string.word_count, wordStatistics.count().toString()) + + } + /** + * Handle scripture/Appendix toggle + */ + private val scriptureToggleClickListener = View.OnClickListener { + isScriptureResultsCurrentlyShown = !isScriptureResultsCurrentlyShown + populateViewResultsAdapter() +// mKeyArrayAdapter!!.notifyDataSetChanged() + searchResultsActionBarManager.setScriptureShown(isScriptureResultsCurrentlyShown) + } + + companion object { + private const val TAG = " MySearchResults" + private const val LIST_ITEM_TYPE = android.R.layout.simple_list_item_2 + } +} + +class SearchResultsPagerAdapter(private val context: Context, fm: FragmentManager, + searchControl: SearchControl, + windowControl: WindowControl, + intent: Intent, + private val mSearchResultsArray:ArrayList, + private val bookStatistics: MutableList, + private val wordStatistics: MutableList, + private val keyWordStatistics: MutableList +) : + FragmentPagerAdapter(fm) { + val searchControl = searchControl + val activeWindowPageManagerProvider = windowControl.activeWindowPageManager + val intent = intent + val windowControl = windowControl + lateinit var verseListFrag: SearchResultsFragment + + + override fun getItem(position: Int): Fragment { + // getItem is called to instantiate the fragment for the given page. + // This initialises the fragment but only if POSITION_NONE is returned from getItemPosition. + // This happens naturally when pages are moving in and out of view but not when we try to refresh. + // The individual fragment code gets called twice - first with no data to display and then with the correct data. + // This is because the page is shown in the GlobalScope in order to allow us to show a progress indicator. + val frag: Fragment + when (position) { + verseTabPosition -> { + frag = SearchResultsFragment(mSearchResultsArray) +// val bundle = Bundle() +// bundle.putString("edttext", "From Activity") +// bundle.putParcelableArrayList("VerseResultList", mSearchResultsArray) +// frag.setArguments(bundle) + frag.searchControl = searchControl + frag.windowControl = windowControl +// frag.activeWindowPageManagerProvider = activeWindowPageManagerProvider + frag.intent = intent + verseListFrag = frag + } + bookTabPosition-> { + frag = SearchBookStatisticsFragment() + frag.bookStatistics = bookStatistics + frag.searchResultsArray = mSearchResultsArray +// bookStatisticsFrag = frag + } + 2-> { + frag = SearchWordStatisticsFragment() + frag.searchResultsArray = mSearchResultsArray + frag.wordStatistics = wordStatistics + frag.keyWordStatistics = keyWordStatistics + } + else -> frag = PlaceholderFragment.newInstance(1) + } + return frag + } + override fun getItemPosition(`object`: Any): Int { + // This line is needed to force a refresh of the fragment. + // It doesn't seem to affect anything adversely, I think because all tabs remain in memory the whole time. + return POSITION_NONE + } + override fun getCount(): Int { + return 3 + } +} diff --git a/app/src/main/java/net/bible/android/view/activity/search/Search.kt b/app/src/main/java/net/bible/android/view/activity/search/Search.kt index 717bb87613..80494d3d6b 100644 --- a/app/src/main/java/net/bible/android/view/activity/search/Search.kt +++ b/app/src/main/java/net/bible/android/view/activity/search/Search.kt @@ -20,12 +20,16 @@ package net.bible.android.view.activity.search import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher import android.util.Log import android.view.MenuItem import android.view.View import android.view.inputmethod.EditorInfo +import android.widget.LinearLayout import android.widget.RadioButton import android.widget.RadioGroup +import android.widget.SeekBar import net.bible.android.activity.R import net.bible.android.activity.databinding.SearchBinding @@ -63,6 +67,31 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { /** get all, any, phrase query limitation */ + + private var fuzzySearchAccuracySelection: Int = 5 + set(value) { + binding.fuzzySearch.text = getString(R.string.search_fuzzy, "(~" + (value * 10).toString() + "%)") + binding.fuzzySearchAccuracy.setProgress(value) + binding.decoratedSearchString.text = decorateSearchString(binding.searchText.text.toString()) + field = value + } + + // I don't know how to do this properly. I want to update the value of proximityWordNumber whenever it is needed (buttons, text, initialising) + // But part of the update is to set the text value itself. But this gets into an infinite loop of course when called when the text value is updated. + // So i have broken it into two parts. One updates everything and one does not update the text field. + private var proximitySearchWordsSelection: Int = 10 + set(value) { + binding.proximityWordNumber.setText(value.toString()) + proximitySearchWordsSelectionFinal = value + binding.decoratedSearchString.text = decorateSearchString(binding.searchText.text.toString()) + field = value + } + private var proximitySearchWordsSelectionFinal: Int = 10 + set(value) { + binding.proximitySearch.text = getString(R.string.search_proximity, "(${value.toString()} ${getString(R.string.words)})") + field = value + } + private val searchType: SearchType get() { return when (wordsRadioSelection) { @@ -75,7 +104,6 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { } } } - /** get OT, NT, or all query limitation * * @return @@ -104,6 +132,12 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { CommonUtils.settings.setLong("search-last-used", System.currentTimeMillis()) buildActivityComponent().inject(this) + // set text for current bible book on appropriate radio button + val currentBookRadioButton = findViewById(R.id.searchCurrentBook) as RadioButton + + // set current book to default and allow override if saved - implies returning via Back button + currentBookName = searchControl.currentBookName + if (!searchControl.validateIndex(documentToSearch)) { Dialogs.showErrorMsg(R.string.error_occurred) { finish() } } @@ -117,6 +151,88 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { } else -> false }} + binding.searchText.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) {} + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + binding.decoratedSearchString.text = decorateSearchString(binding.searchText.text.toString()) + } + }) + val sectionRadioGroup = findViewById(R.id.bibleSectionGroup) as RadioGroup + sectionRadioGroup.setOnCheckedChangeListener { group, checkedId -> + sectionRadioSelection = checkedId + CommonUtils.settings.setInt("search_bible_section_group_prompt", checkedId) + enableSearchControls() + } + val wordsRadioGroup = findViewById(R.id.wordsGroup) as RadioGroup + wordsRadioGroup.setOnCheckedChangeListener { group, checkedId -> + wordsRadioSelection = checkedId + CommonUtils.settings.setInt("search_words_group_prompt", checkedId) + enableSearchControls() + } + binding.includeAllEndings.setOnClickListener { + CommonUtils.settings.setBoolean("search_include_all_endings", binding.includeAllEndings.isChecked) + if (binding.includeAllEndings.isChecked) { + binding.proximitySearch.isChecked = false + binding.strongsSearch.isChecked = false + binding.fuzzySearch.isChecked = false + } + enableSearchControls() + } + binding.rememberSearchText.setOnClickListener { + CommonUtils.settings.setBoolean("search_remember_search_text", binding.rememberSearchText.isChecked) + } + binding.fuzzySearch.setOnClickListener { + if (binding.fuzzySearch.isChecked) { + binding.proximitySearch.isChecked = false + binding.strongsSearch.isChecked = false + binding.includeAllEndings.isChecked = false + } + enableSearchControls() + } + binding.fuzzySearchAccuracy.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + fuzzySearchAccuracySelection = progress + } + override fun onStartTrackingTouch(seekBar: SeekBar?) {} + override fun onStopTrackingTouch(seekBar: SeekBar?) {} + }) + + binding.proximitySearch.setOnClickListener { + if (binding.proximitySearch.isChecked) { + binding.fuzzySearch.isChecked = false + binding.strongsSearch.isChecked = false + binding.includeAllEndings.isChecked = false + } + enableSearchControls() + } + binding.proximityButtonAdd.setOnClickListener {proximitySearchWordsSelection = binding.proximityWordNumber.text.toString().toInt() + 1} + binding.proximityButtonSubtract.setOnClickListener {proximitySearchWordsSelection = binding.proximityWordNumber.text.toString().toInt() - 1} + binding.proximityWordNumber.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) {} + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {proximitySearchWordsSelectionFinal = s.toString().toInt()} + }) + + binding.strongsSearch.setOnClickListener { + if (binding.strongsSearch.isChecked) { + binding.includeAllEndings.isChecked = false + binding.fuzzySearch.isChecked = false + binding.proximitySearch.isChecked = false + } + enableSearchControls() + } + binding.greekOrHebrewGroup.setOnCheckedChangeListener { group, checkedId -> + CommonUtils.settings.setInt("search_greek_or_hebrew", checkedId) + enableSearchControls() + } + binding.decoratedTextShow.setOnCheckedChangeListener { group, checkedId -> + // I would like to show the actual text that is being used for the search somewhere. + // I think it would be good to show it on the list screen. Could also show it here. + + CommonUtils.settings.setBoolean("search_show_decorated_text", checkedId) + enableSearchControls() + } binding.submit.setOnClickListener { onSearch() } //searchText.setOnKeyListener(OnKeyListener { v, keyCode, event -> @@ -129,6 +245,27 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { // false //}) + // Initialise controls + binding.includeAllEndings.isChecked = CommonUtils.settings.getBoolean("search_include_all_endings", false) + binding.rememberSearchText.isChecked = CommonUtils.settings.getBoolean("search_remember_search_text", false) + + binding.proximitySearch.isChecked = CommonUtils.settings.getBoolean("search_proximity",false) + proximitySearchWordsSelection = CommonUtils.settings.getInt("search_proximity_words", 10) + + binding.wordsGroup.check(CommonUtils.settings.getInt("search_words_group_prompt", 0)) + wordsRadioSelection = binding.wordsGroup.checkedRadioButtonId + + binding.bibleSectionGroup.check(CommonUtils.settings.getInt("search_bible_section_group_prompt", 0)) + sectionRadioSelection = binding.bibleSectionGroup.checkedRadioButtonId + + binding.fuzzySearch.isChecked = CommonUtils.settings.getBoolean("search_fuzzy", false) + fuzzySearchAccuracySelection = CommonUtils.settings.getInt("search_fuzzy_accuracy", 5) + + binding.strongsSearch.isChecked = CommonUtils.settings.getBoolean("search_strongs",false) + binding.greekOrHebrewGroup.check(CommonUtils.settings.getInt("search_greek_or_hebrew",0)) + + binding.decoratedTextShow.isChecked = CommonUtils.settings.getBoolean("search_show_decorated_text",false) + // pre-load search string if passed in val extras = intent.extras if (extras != null) { @@ -136,10 +273,10 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { if (StringUtils.isNotEmpty(text)) { binding.searchText.setText(text) } + } else { + if (binding.rememberSearchText.isChecked) binding.searchText.setText(CommonUtils.settings.getString("search_text", "")) } - val wordsRadioGroup = findViewById(R.id.wordsGroup) as RadioGroup - wordsRadioGroup.setOnCheckedChangeListener { group, checkedId -> wordsRadioSelection = checkedId } if (extras != null) { val wordsSelection = extras.getInt(WORDS_SELECTION_SAVE, -1) if (wordsSelection != -1) { @@ -147,8 +284,6 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { } } - val sectionRadioGroup = findViewById(R.id.bibleSectionGroup) as RadioGroup - sectionRadioGroup.setOnCheckedChangeListener { group, checkedId -> sectionRadioSelection = checkedId } if (extras != null) { val sectionSelection = extras.getInt(SECTION_SELECTION_SAVE, -1) if (sectionSelection != -1) { @@ -156,11 +291,6 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { } } - // set text for current bible book on appropriate radio button - val currentBookRadioButton = findViewById(R.id.searchCurrentBook) as RadioButton - - // set current book to default and allow override if saved - implies returning via Back button - currentBookName = searchControl.currentBookName if (extras != null) { val currentBibleBookSaved = extras.getString(CURRENT_BIBLE_BOOK_SAVE) if (currentBibleBookSaved != null) { @@ -169,9 +299,40 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { } currentBookRadioButton.text = currentBookName + binding.textClear.setOnClickListener({binding.searchText.setText("")}) + enableSearchControls() + Log.i(TAG, "Finished displaying Search view") } + fun enableSearchControls() { + + // Set control visibility + binding.fuzzySearchDetailsLayout.visibility = if (binding.fuzzySearch.isChecked) View.VISIBLE else View.GONE + binding.proximityDetailsLayout.visibility = if (binding.proximitySearch.isChecked) View.VISIBLE else View.GONE + binding.strongsDetailLayout.visibility = if (binding.strongsSearch.isChecked) View.VISIBLE else View.GONE + binding.decoratedSearchDetailLayout.visibility = if (binding.decoratedTextShow.isChecked) View.VISIBLE else View.GONE + + enableLayout(binding.fuzzySearchLayout, binding.allWords.id == wordsRadioSelection) + enableLayout(binding.fuzzySearchDetailsLayout, binding.fuzzySearch.isChecked && binding.fuzzySearch.isEnabled) + + enableLayout(binding.strongsDetailLayout, (binding.strongsSearch.isChecked && binding.strongsSearch.isEnabled)) + + binding.searchOldTestament.isEnabled = !binding.strongsSearch.isChecked || (binding.strongsSearch.isEnabled && binding.strongsSearch.isChecked && binding.searchHebrew.isChecked) + binding.searchNewTestament.isEnabled = !binding.strongsSearch.isChecked || (binding.strongsSearch.isEnabled && binding.strongsSearch.isChecked && binding.searchGreek.isChecked) + + binding.decoratedSearchString.text = decorateSearchString(binding.searchText.text.toString()) + } + + fun enableLayout(layout: LinearLayout, isEnabled: Boolean) { + for (i in 0 until layout.childCount) { + val view: View = layout.getChildAt(i) + if (view is LinearLayout) {enableLayout(view,isEnabled)} + view.isEnabled = isEnabled + } + layout.isEnabled = isEnabled + } + override fun onOptionsItemSelected(item: MenuItem): Boolean { when(item.itemId) { R.id.rebuildIndex -> { @@ -191,7 +352,6 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { binding.searchText.requestFocus() } - fun onRebuildIndex(v: View?) { startActivity(Intent(this, SearchIndex::class.java)) finish() @@ -204,6 +364,13 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { var text = binding.searchText.text.toString() if (!StringUtils.isEmpty(text)) { + CommonUtils.settings.setString("search_text", text) + CommonUtils.settings.setBoolean("search_fuzzy", binding.fuzzySearch.isChecked) + CommonUtils.settings.setInt("search_fuzzy_accuracy", fuzzySearchAccuracySelection) + CommonUtils.settings.setBoolean("search_proximity", binding.proximitySearch.isChecked) + CommonUtils.settings.setInt("search_proximity_words", proximitySearchWordsSelectionFinal) + CommonUtils.settings.setBoolean("search_strongs", binding.strongsSearch.isChecked) + // update current intent so search is restored if we return here via history/back // the current intent is saved by HistoryManager intent.putExtra(SEARCH_TEXT_SAVE, text) @@ -211,12 +378,13 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { intent.putExtra(SECTION_SELECTION_SAVE, sectionRadioSelection) intent.putExtra(CURRENT_BIBLE_BOOK_SAVE, currentBookName) - text = searchControl.decorateSearchString(text, searchType, bibleSection, currentBookName) +// text = searchControl.decorateSearchString(text, searchType, bibleSection, currentBookName) + text = decorateSearchString(text) Log.i(TAG, "Search text:$text") // specify search string and doc in new Intent; // if doc is not specifed a, possibly invalid, doc may be used when returning to search via history list e.g. search bible, select dict, history list, search results - val intent = Intent(this, SearchResults::class.java) + val intent = Intent(this, MySearchResults::class.java) intent.putExtra(SearchControl.SEARCH_TEXT, text) val currentDocInitials = documentToSearch.initials intent.putExtra(SearchControl.SEARCH_DOCUMENT, currentDocInitials) @@ -228,6 +396,14 @@ class Search : CustomTitlebarActivityBase(R.menu.search_actionbar_menu) { } } + private fun decorateSearchString(searchString: String): String { + val fuzzyAccuracy = if (binding.fuzzySearch.isChecked && binding.fuzzySearch.isEnabled) fuzzySearchAccuracySelection.toDouble()/10 else null + val proximityWords = if (binding.proximitySearch.isChecked && binding.proximitySearch.isEnabled) proximitySearchWordsSelectionFinal else null + val strongs = if (binding.strongsSearch.isChecked && binding.strongsSearch.isEnabled) {if (binding.searchGreek.isChecked) 'G' else 'H'} else null + + return searchControl.decorateSearchString(searchString, searchType, bibleSection, currentBookName, binding.includeAllEndings.isChecked, fuzzyAccuracy, proximityWords, strongs) + } + companion object { private const val SEARCH_TEXT_SAVE = "Search" diff --git a/app/src/main/java/net/bible/android/view/activity/search/SearchHighlight.kt b/app/src/main/java/net/bible/android/view/activity/search/SearchHighlight.kt new file mode 100644 index 0000000000..ac3707619e --- /dev/null +++ b/app/src/main/java/net/bible/android/view/activity/search/SearchHighlight.kt @@ -0,0 +1,191 @@ +package net.bible.android.view.activity.search + +import android.graphics.Typeface +import android.text.Html +import android.text.Spannable +import android.text.SpannableString +import android.text.style.StyleSpan +import android.util.Log +import org.jdom2.Element +import org.jdom2.Text +import java.lang.Exception +import java.util.regex.Pattern + +class SearchHighlight(searchTerms: String) { + + val isStrongsSearch = searchTerms.contains("strong:") + val elementsToExclude: List = listOf("note", "reference") + val elementsToInclude: List = listOf("w", "transChange", "divineName", "seg") + val consecutiveWordsRegex = Regex("(]*>)([^<>]*?)(<\\/b>)(\\s+)\\1([\\s\\S]*?)\\3", RegexOption.IGNORE_CASE) + val strongsSearchPattern: Pattern = Pattern.compile(prepareStrongsSearchTerm(searchTerms) + "[a-z]?\\b", Pattern.CASE_INSENSITIVE) // search on a word boundary (eg find strong:g0123 not strong:g01234567 + + private val preparedSearchWordsPatternList:List = // Build a list of Patterns representing each search word. + splitSearchTerms(searchTerms).map { + var searchWord = prepareSearchWord(it) + searchWord = if (it.contains("*") or it.contains("~")) { + "\\b$searchWord[\\w\\'\\-]*\\b" // Match whole words including with hyphens and apostrophes + } else { + "\\b$searchWord\\b" + } + Pattern.compile(searchWord, Pattern.CASE_INSENSITIVE) + } + + fun generateSpannableFromVerseElement(verseElement: Element): SpannableString { + /* Takes a verse in Element form and rebuilds the verse in string form. + * Interestingly it is faster to get the text in Element form and convert it myself than to call 'getSearchResultVerseText'. + * Once the verse is in String format which includes tags for Strongs numbers it is passed to 'generateSpannableFromVerseString' + * which does the actual conversion to a spannable. + `*/ + var verseString = "" + try { + // TODO: Strongs searches are handled differently to normal searches and so cannot be combined either with a normal search term or other strongs searches. This should be done better. + + // Part 1: Highlight any strongs words. The raw verse text is returned with added for the strongs words. We always need to process this just to get the plaintext + val verses = verseElement.getChildren("verse") + + for (verse in verses) { + verseString += processElementChildren(verse, "", false) + } + + if (isStrongsSearch) { + // Check for highlighted consecutive words and merge them into a single highlighted phrase. Some translations will indicate multiple + // consecutive lemma spans with the same strongs number when in fact all spans represent the single original language word. + // This messes with the search statistics as it uses the bolded words to tally search hits by word. + var verseStringLength = 0 + while (verseStringLength != verseString.length) { + verseStringLength = verseString.length + // This pattern matches two consecutive tag spans separate only by white space and replaces them with a single tag. + val match = consecutiveWordsRegex.find(verseString) + if (match != null) { + val matches = match.groupValues + verseString = verseString.replace( + matches[0], + "${matches[1]}${matches[2]}${matches[4]}${matches[5]}${matches[3]}" + ) + } + } + } + } catch (e: Exception) { + Log.w("SEARCH", e.message!!) + } finally { + return generateSpannableFromVerseString(verseString) + } + } + + fun generateSpannableFromVerseString(verseString:String): SpannableString { + + val spannableText = SpannableString(Html.fromHtml(verseString)) // We started with an XML verse which got turned into a string with tags which is now turned into a spannable + try { + + // Part 2: Find and highlight the normal (non-strongs) words or phrases in the PLAIN text verse + for (searchPattern in preparedSearchWordsPatternList) { + val m = searchPattern.matcher(spannableText) + while (m.find()) { + spannableText.setSpan( + StyleSpan(Typeface.BOLD), + m.start(), + m.end(), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ) + } + } + + } catch (e: Exception) { + Log.w("SEARCH", e.message!!) + } finally { + return spannableText + } + } + + private fun processElementChildren( + parentElement: Element, + verseStringInitial: String?, + isBoldInitial: Boolean + ): String? { + // Recursive method to walk the verse element tree ignoring tags like 'note' that should not be shown in the search results + // and including tags like 'w' that should be included. This routine is needed only to do searches on lemma attributes. That + // is why bolding only occurs in that part of the code. + var verseString = verseStringInitial + var isBold = isBoldInitial + for (el in parentElement.content) { + if (el is Element) { + if (elementsToInclude.contains(el.name)) { + isBold = try { + if (isStrongsSearch) { + val lemma = el.getAttributeValue("lemma") + lemma != null && strongsSearchPattern.matcher(lemma.trim { it <= ' ' }).find() + } else { + false + } + } catch (e: Exception) { + false + } + // Only leaf nodes should have their text appended. If a node has child tags, the text will be passed as one of the children . + if (el.children.isEmpty()) verseString += buildElementText(el.text, isBold) + } + if (el.children.isNotEmpty() && !elementsToExclude.contains(el.name)) { + verseString = processElementChildren(el, verseString, isBold) + } + } else if (el is Text) { + verseString += buildElementText(el.text, false) + } else { + verseString += buildElementText(el.toString(), false) + } + } + return verseString + } + + private fun buildElementText(elementText: String, isBold: Boolean): String { + return if (isBold) { + String.format("%s", elementText) + } else { + elementText + } + } + + private fun prepareStrongsSearchTerm(_searchTerms: String): String { + // Replaces strong:g00123 or strong:g123 with REGEX strong:g0*123. This is needed because the search term submitted by the 'Find all occurrences includes extra zeros + // The capitalisation is not important since we do a case insensitive search + var searchTerms = _searchTerms + searchTerms = searchTerms.replace("strong:g0*".toRegex(RegexOption.IGNORE_CASE), "strong:g0*") + searchTerms = searchTerms.replace("strong:h0*".toRegex(RegexOption.IGNORE_CASE), "strong:h0*") + searchTerms = searchTerms.replace("+", "") // Remove + which indicates AND searches + + return searchTerms + } + + private fun splitSearchTerms(searchTerms: String): Array { + // Find the individual words that we are looking for so we can highlight them. + // Proximity searches wrap the words in quotes the quotes need to be removed. + val fuzzySearch = searchTerms.indexOf("~") + val newSearchTerms = if (fuzzySearch > -1) { + searchTerms.substring(0,fuzzySearch).replace("\"","") + } else searchTerms + // Split the search terms on space characters that are not enclosed in double quotes. + // Eg: 'moses "burning bush"' -> "moses" and "burning bush" + return newSearchTerms.split("\\s+(?=(?:\"(?:\\\\\"|[^\"])+\"|[^\"])+$)".toRegex()).toTypedArray() + } + + private fun prepareSearchWord(searchWord: String): String { + // Need to clean up the search word itself before trying to find the searchWord in the text. Routine is called for each part of the search term + // Eg: '+"burning bush"' -> 'burning bush' + val fuzzySearch = searchWord.indexOf("~") + var newSearchWord = if (fuzzySearch > -1) { searchWord.substring(0,fuzzySearch) } else searchWord + newSearchWord = newSearchWord.replace("\"", "") // Remove quotes which indicate phrase searches + newSearchWord = newSearchWord.replace("+", "") // Remove + which indicates AND searches + newSearchWord = newSearchWord.replace("?", "\\p{L}") // Handles any letter from any language + if (newSearchWord.isNotEmpty()) { + newSearchWord = if ((newSearchWord.substring(newSearchWord.length - 1) == "*") or (fuzzySearch>-1)){ + // The last character in the search is a * so remove it since the default sword search assumes a wildcard search + newSearchWord.replace("*", "") + } else { + // A * found inside a search term should probably be ignored. + // It can happen normally as part of a strongs search since the regex expression uses a * to find any number of leading 0s. + newSearchWord.replace("*", "\b") // Match on a word boundary - I am not sure why this was needed. + + } + } + return newSearchWord + } + +} diff --git a/app/src/main/java/net/bible/android/view/activity/search/SearchResultsAdapter.java b/app/src/main/java/net/bible/android/view/activity/search/SearchResultsAdapter.java new file mode 100644 index 0000000000..9bec39ee3f --- /dev/null +++ b/app/src/main/java/net/bible/android/view/activity/search/SearchResultsAdapter.java @@ -0,0 +1,105 @@ +package net.bible.android.view.activity.search; + +import android.content.Context; +import android.text.Html; +import android.text.SpannableString; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.TextView; + +import net.bible.android.activity.R; + +import java.util.ArrayList; + +import org.crosswire.jsword.book.Book; +import org.crosswire.jsword.book.Books; +import org.crosswire.jsword.passage.Key; +import org.crosswire.jsword.passage.NoSuchKeyException; + +public class SearchResultsAdapter extends ArrayAdapter { + + private int resource; + private ArrayList arrayList; + private Context context; + + public SearchResultsAdapter(Context _context, int _resource, ArrayList arrayList) { + super(_context, _resource, arrayList); + this.resource = _resource; + this.arrayList=arrayList; + this.context=_context; + } + + private void scaleTextView(TextView textView, Float scale) { + if (textView.getTag()==null) textView.setTag(Float.valueOf(textView.getTextSize())); + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, (Float) textView.getTag() * scale); + } + + @Override + public View getView(int position, View convertView, ViewGroup parent) { + SearchResultsData resultData=arrayList.get(position); + Float scaleText = 1.2F; // TODO: I would like to add an application preference that allows the user to set the size of their results list + if(convertView==null) { + + LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + convertView=inflater.inflate(R.layout.search_results_statistics_row_verse, null); + + TextView reference=convertView.findViewById(R.id.reference); + reference.setText(resultData.reference); + scaleTextView(reference, scaleText); + + TextView translation=convertView.findViewById(R.id.translation); + translation.setText(resultData.translation); + scaleTextView(translation, scaleText); + + // Get the text of the verse + Book book = Books.installed().getBook(resultData.translation); + try { + Key key = book.getKey(resultData.osisKey); + + String verseHtml = resultData.verseHtml.trim(); + + // The 'toHtml' method wraps the string in

which puts a large margin at the bottom of the verse. Need to remove this. + try{ + verseHtml= replaceString(verseHtml,"

", ""); + verseHtml= replaceString(verseHtml,"

", ""); + }catch (Exception e) {} + + SpannableString verseTextHtml = new SpannableString(Html.fromHtml(verseHtml)); + + TextView verse=convertView.findViewById(R.id.verse); + verse.setText(verseTextHtml); + scaleTextView(verse, scaleText); + + } catch (NoSuchKeyException e) { + e.printStackTrace(); + } + } + return convertView; + } + + private String replaceString(String initialString, String first, String second) + { + StringBuilder b = new StringBuilder(initialString); + b.replace(initialString.lastIndexOf(first), initialString.lastIndexOf(first)+first.length(),second ); + return b.toString(); + } + + @Override + public int getItemViewType(int position) { + return position; + } + @Override + public int getViewTypeCount() { + if(getCount()<1) return 1; + return getCount(); + } + @Override + public boolean isEmpty() { + return false; + } + + +} diff --git a/app/src/main/java/net/bible/android/view/activity/search/SearchResultsFragments.kt b/app/src/main/java/net/bible/android/view/activity/search/SearchResultsFragments.kt new file mode 100644 index 0000000000..6dd5e8ce9e --- /dev/null +++ b/app/src/main/java/net/bible/android/view/activity/search/SearchResultsFragments.kt @@ -0,0 +1,295 @@ +package net.bible.android.view.activity.search + +import android.app.Activity +import android.content.Intent +import android.content.res.ColorStateList +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.FrameLayout +import androidx.fragment.app.Fragment +import android.widget.ListView +import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import com.google.android.material.tabs.TabLayout +import net.bible.android.activity.databinding.SearchResultsStatisticsFragmentVerseBinding +import net.bible.android.activity.R +import net.bible.android.activity.databinding.SearchResultsStatisticsFragmentByBinding +import net.bible.android.control.search.SearchControl +import net.bible.android.view.activity.base.Dialogs +import net.bible.android.view.activity.page.MainBibleActivity +import org.apache.commons.lang3.StringUtils +import org.crosswire.jsword.passage.Key +import net.bible.service.common.CommonUtils +import net.bible.service.sword.SwordDocumentFacade +import net.bible.android.control.page.window.WindowControl +import javax.inject.Inject + +private lateinit var displayedResultsArray: ArrayList +private var isSearchResultsFiltered = false + + +class PlaceholderFragment: Fragment() { + + private var _binding: SearchResultsStatisticsFragmentVerseBinding? = null + + // This property is only valid between onCreateView and + // onDestroyView. + private val binding get() = _binding!! + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + _binding = SearchResultsStatisticsFragmentVerseBinding.inflate(inflater, container, false) + val root = binding.root + + return root + } + + companion object { + /** + * The fragment argument representing the section number for this + * fragment. + */ + private const val ARG_SECTION_NUMBER = "section_number" + + /** + * Returns a new instance of this fragment for the given section + * number. + */ + @JvmStatic + fun newInstance(sectionNumber: Int): PlaceholderFragment { + return PlaceholderFragment().apply { + arguments = Bundle().apply { + putInt(ARG_SECTION_NUMBER, sectionNumber) + } + } + } + } + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +private fun setResultsAdapter(resultsArray:ArrayList, activity: Activity): SearchResultsAdapter { + // This function is called from both the SearchResultsFragment and the SearchWordStatistics (for filtering) + val tabhost = activity.findViewById(R.id.tabs) as TabLayout + val verseCount = if(resultsArray.count()==0) "..." else resultsArray.count().toString() + tabhost.getTabAt(verseTabPosition)!!.text = CommonUtils.resources.getString(R.string.verse_count, verseCount) // The count needs to be set here because it can be changed when filtering the list by word + + displayedResultsArray = resultsArray + return SearchResultsAdapter(activity, android.R.layout.simple_list_item_2, + displayedResultsArray as java.util.ArrayList? + ) +} +class SearchResultsFragment(val mSearchResultsArray:ArrayList) : Fragment() { + private var _binding: SearchResultsStatisticsFragmentVerseBinding? = null + private val binding get() = _binding!! + lateinit var arrayAdapter: ArrayAdapter + + var searchControl: SearchControl? = null + lateinit var intent: Intent + lateinit var windowControl: WindowControl + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + _binding = SearchResultsStatisticsFragmentVerseBinding.inflate(inflater, container, false) + val root = binding.root + + val resultList: ListView = binding.searchResultsList + + arrayAdapter = setResultsAdapter(mSearchResultsArray, requireActivity()) + resultList.adapter = arrayAdapter + resultList.descendantFocusability = ViewGroup.FOCUS_BEFORE_DESCENDANTS; + resultList.setChoiceMode(ListView.CHOICE_MODE_SINGLE); + resultList.isVerticalScrollBarEnabled = false + + resultList.setOnItemClickListener { parent, view, position, id -> + Toast.makeText(resultList.context, "Hi",Toast.LENGTH_LONG) + try { // no need to call HistoryManager.addHistoryItem() here because PassageChangeMediator will tell HistoryManager a change is about to occur + verseSelected(mCurrentlyDisplayedSearchResults[displayedResultsArray[position].id!!]) + } catch (e: Exception) { + Log.e("SearchResults", "Selection error", e) + Dialogs.showErrorMsg(R.string.error_occurred, e) + } + } + return root + } + + private fun verseSelected(key: Key?) { + Log.i("SearchResults.TAG", "chose:$key") + if (key != null) { // which doc do we show + searchControl + var targetDocInitials = intent.extras!!.getString(SearchControl.TARGET_DOCUMENT) + if (StringUtils.isEmpty(targetDocInitials)) { + targetDocInitials = windowControl.activeWindowPageManager.currentPage.currentDocument!!.initials + } + val swordDocumentFacade = SwordDocumentFacade() + val targetBook = swordDocumentFacade.getDocumentByInitials(targetDocInitials) + windowControl.activeWindowPageManager.setCurrentDocumentAndKey(targetBook, key) + // this also calls finish() on this Activity. If a user re-selects from HistoryList then a new Activity is created + val intent = Intent(this.context, MainBibleActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + } + startActivity(intent) + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} + +class SearchBookStatisticsFragment : Fragment() { + private var _binding: SearchResultsStatisticsFragmentByBinding? = null + + private val binding get() = _binding!! + var bookStatistics = mutableListOf() + lateinit var searchResultsArray: ArrayList + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the layout for this fragment + _binding = SearchResultsStatisticsFragmentByBinding.inflate(inflater, container, false) + val root = binding.root + + val statisticsLayout = binding.statisticsLayout + binding.showKeyWordOnly.visibility = View.GONE + + val maxCount: Int = bookStatistics.maxOfOrNull { it.count } ?: 0 + bookStatistics.map { + + val statsRow: View = inflater.inflate( + R.layout.search_results_statistics_row_by_book, + statisticsLayout, false + ) + var button = statsRow.findViewById