diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardFilters.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardFilters.kt index 167ba9b..c23b7c4 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardFilters.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardFilters.kt @@ -31,7 +31,8 @@ import com.fabledsword.inkwell.core.NoteFacets * shared with me. Reminders and dates have their own lenses, here as on the web. * * Only the main board filters, as on the web. Opening Archive, Trash or a tag from - * the drawer starts that view unfiltered, and search runs over everything. + * the drawer starts that view unfiltered. A search narrows whichever view is open, + * and on the main board it combines with these. */ data class BoardFilters( val labelIds: Set = emptySet(), @@ -42,13 +43,17 @@ data class BoardFilters( val count: Int get() = labelIds.size + (if (hasAttachment) 1 else 0) + (if (sharedWithMe) 1 else 0) - /** The core's facets for these, or null when none are on. */ - fun facets(): NoteFacets? = - if (count == 0) { + /** + * The core's facets for these and the search box's [text], or null when there is + * nothing to narrow by. The core ANDs them, the same as the server's query. + */ + fun facets(text: String = ""): NoteFacets? { + val q = text.trim().ifEmpty { null } + return if (count == 0 && q == null) { null } else { NoteFacets( - q = null, + q = q, label = labelIds.toList().ifEmpty { null }, hasAttachment = if (hasAttachment) true else null, createdAfter = null, @@ -56,6 +61,7 @@ data class BoardFilters( shared = if (sharedWithMe) SHARED_WITH_ME else null, ) } + } fun toggleLabel(id: String) = copy(labelIds = if (id in labelIds) labelIds - id else labelIds + id) } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt index 8ea766a..3a431a8 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/BoardViewModel.kt @@ -83,20 +83,22 @@ data class BoardState( */ val editingSession: Long = 0, ) { - /** Search overrides the destination while there is a query to run. */ + /** + * A search narrows the board in view rather than replacing it, as on the web, + * where the box writes `?q=` beside the Filters panel's facets and one query + * answers both (Scribe #2942). "These words, in notes tagged grocery" is the + * question tags exist to help ask (note 2930). + */ val searching: Boolean get() = query.isNotBlank() - /** - * The main board only, and not while searching, as on the web: a search spans - * everything, so a filter over its hits would be a control that does nothing. - */ - val filterable: Boolean get() = destination == Destination.Notes && !searching + /** The main board only, as on the web, whose Filters panel is the main board's. */ + val filterable: Boolean get() = destination == Destination.Notes /** * The whole board in one order, as on the web: not a search, another view, or a * filtered subset, whose order would be renumbered against notes it can't see. */ - val reorderable: Boolean get() = filterable && filters.count == 0 + val reorderable: Boolean get() = filterable && filters.count == 0 && !searching } /** @@ -169,7 +171,8 @@ class BoardViewModel( try { val destination = state.destination val filters = state.filters - val notes = withContext(Dispatchers.IO) { load(destination, filters) } + val text = state.query + val notes = withContext(Dispatchers.IO) { load(destination, filters, text) } state.copy(notes = notes, loading = false, error = null) } catch (e: Exception) { // Broad by intent: the board must render something for any @@ -180,19 +183,25 @@ class BoardViewModel( } } + /** The notes [destination] shows, narrowed by the board's filters and by [text]. */ private fun load( destination: Destination, filters: BoardFilters, - ): List = - when (destination) { - Destination.Notes -> core.listNotes(query(VIEW_NOTES, facets = filters.facets())) - Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE)) - Destination.Trash -> core.listNotes(query(VIEW_TRASH)) + text: String, + ): List { + // Only the main board has filters; every board view takes the search text. + val searchOnly = BoardFilters().facets(text) + return when (destination) { + Destination.Notes -> core.listNotes(query(VIEW_NOTES, facets = filters.facets(text))) + Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE, facets = searchOnly)) + Destination.Trash -> core.listNotes(query(VIEW_TRASH, facets = searchOnly)) // Not a board view: the core models reminders as its own query, since - // "has a reminder" cuts across archived and active alike. + // "has a reminder" cuts across archived and active alike. `search` moves + // a search off this view, so it never has text to apply. Destination.Reminders -> core.reminderNotes() - is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id)) + is Destination.WithLabel -> core.listNotes(query(VIEW_NOTES, labelId = destination.id, facets = searchOnly)) } + } /** * Reload the drawer's tags, and leave a lens whose tag no longer exists. @@ -231,7 +240,15 @@ class BoardViewModel( } fun search(text: String) { - state = state.copy(query = text) + // Reminders is a list, not a board view, so a search there runs over the main + // board — which is where the web sends a search typed off the board. + val destination = + if (text.isNotBlank() && state.destination == Destination.Reminders) { + Destination.Notes + } else { + state.destination + } + state = state.copy(query = text, destination = destination) searchJob?.cancel() if (text.isBlank()) { @@ -247,7 +264,8 @@ class BoardViewModel( state = state.copy(loading = true) state = try { - val hits = withContext(Dispatchers.IO) { core.searchNotes(text) } + val filters = state.filters + val hits = withContext(Dispatchers.IO) { load(destination, filters, text) } state.copy(notes = hits, loading = false, error = null) } catch (e: Exception) { state.copy(loading = false, error = e.message ?: FALLBACK_ERROR) @@ -569,13 +587,7 @@ class BoardViewModel( try { val updated = withContext(Dispatchers.IO) { block(core) } val notes = - withContext(Dispatchers.IO) { - if (state.searching) { - core.searchNotes(state.query) - } else { - load(state.destination, state.filters) - } - } + withContext(Dispatchers.IO) { load(state.destination, state.filters, state.query) } // On IO, not here: re-deriving the alarm reads every note // that carries a reminder, and this line runs on the main // thread — the coroutine is back from its withContext by now. diff --git a/android/app/src/test/java/com/fabledsword/inkwell/ui/BoardFiltersTest.kt b/android/app/src/test/java/com/fabledsword/inkwell/ui/BoardFiltersTest.kt index de04deb..d435744 100644 --- a/android/app/src/test/java/com/fabledsword/inkwell/ui/BoardFiltersTest.kt +++ b/android/app/src/test/java/com/fabledsword/inkwell/ui/BoardFiltersTest.kt @@ -20,12 +20,32 @@ class BoardFiltersTest { assertEquals(setOf("a", "b"), facets.label?.toSet()) assertEquals(true, facets.hasAttachment) assertEquals("with_me", facets.shared) - // Dates are Timeline's and text is the search bar's; the filters never set them. + // Dates are Timeline's, and with nothing typed there is no text to search. assertNull(facets.q) assertNull(facets.createdAfter) assertNull(facets.createdBefore) } + @Test + fun `the search text and the tags go to the core together, so it can AND them`() { + val facets = requireNotNull(BoardFilters(labelIds = setOf("grocery")).facets("milk")) + assertEquals("milk", facets.q) + assertEquals(listOf("grocery"), facets.label) + } + + @Test + fun `search text alone is enough to narrow, with no filters on`() { + val facets = requireNotNull(BoardFilters().facets(" milk ")) + // Trimmed, as the web trims the box before it writes ?q=. + assertEquals("milk", facets.q) + assertNull(facets.label) + } + + @Test + fun `a box holding only spaces narrows nothing`() { + assertNull(BoardFilters().facets(" ")) + } + @Test fun `a switch that is off is left out rather than sent as false`() { val facets = requireNotNull(BoardFilters(labelIds = setOf("a")).facets()) diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index eeabbcc..98ab6f0 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -171,17 +171,6 @@ impl Inkwell { .map_err(CoreError::store) } - /// Full-text search across titles, bodies and checklist items. - /// - /// The core owns the query — it searches the same columns the desktop and web - /// search, so "what matches" cannot drift between surfaces. Filtering the - /// board list in Kotlin would have been less code and a different product. - pub fn search_notes(&self, query: String) -> Result, CoreError> { - let conn = self.db.conn().map_err(CoreError::store)?; - let notes = local::store::search(&conn, &query).map_err(CoreError::store)?; - Ok(notes.into_iter().map(Note::from).collect()) - } - /// Notes carrying a reminder, soonest first. /// /// A dedicated call rather than a board `view`, because that is how the core diff --git a/core/src/local/store.rs b/core/src/local/store.rs index be62322..4478181 100644 --- a/core/src/local/store.rs +++ b/core/src/local/store.rs @@ -515,15 +515,6 @@ pub fn titles(conn: &Connection) -> rusqlite::Result> { .collect()) } -pub fn search(conn: &Connection, q: &str) -> rusqlite::Result> { - let pat = format!("%{}%", escape_like(q)); - notes_where( - conn, - "SELECT id FROM notes WHERE trashed = 0 AND body LIKE ?1 ESCAPE '\\' ORDER BY updated_at DESC", - [&pat], - ) -} - // ---- notes: write ----------------------------------------------------------- pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result {