From 87725ecab7d5488784b23d886bb9621688be1138 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 08:19:50 -0400 Subject: [PATCH] android: search narrows the board in view, and combines with its filters As on the web, the search box is now one more facet on the board you are looking at, rather than a separate unfiltered search. "These words, in notes tagged grocery" works: on the main board the text is sent to the core together with the Filters sheet's tags, attachment and shared switches, and the core ANDs them in list_notes. Archive, Trash and a tag's view take the text alone. A search typed on Reminders, which is not a board view, moves to the main board, as the web does. The Filters chip stays while you search; it was hidden before, on the mistaken claim that the web hides its filters too. Drag-to-reorder stays off during a search, since a filtered subset can't be renumbered against notes it can't see. store::search and the FFI's search_notes had no other callers, and are removed. They also searched archived notes and ignored pinning, which the board's query does not. Scribe #2942. Co-Authored-By: Claude Opus 5.5 --- .../fabledsword/inkwell/ui/BoardFilters.kt | 16 +++-- .../fabledsword/inkwell/ui/BoardViewModel.kt | 60 +++++++++++-------- .../inkwell/ui/BoardFiltersTest.kt | 22 ++++++- android/ffi/src/lib.rs | 11 ---- core/src/local/store.rs | 9 --- 5 files changed, 68 insertions(+), 50 deletions(-) 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 {