android: search narrows the board in view, and combines with its filters
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 9s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 7s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python tests (push) Successful in 17s
Android / Core and FFI clippy and tests (push) Successful in 1m5s
CI & Build / integration (push) Successful in 1m42s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m24s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m17s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m59s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m14s
Android / Build the server image (push) Successful in 1s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 08:19:50 -04:00
co-authored by Claude Opus 5.5
parent 5fa261cea7
commit 87725ecab7
5 changed files with 68 additions and 50 deletions
@@ -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. * 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 * 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( data class BoardFilters(
val labelIds: Set<String> = emptySet(), val labelIds: Set<String> = emptySet(),
@@ -42,13 +43,17 @@ data class BoardFilters(
val count: Int val count: Int
get() = labelIds.size + (if (hasAttachment) 1 else 0) + (if (sharedWithMe) 1 else 0) 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? = * The core's facets for these and the search box's [text], or null when there is
if (count == 0) { * 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 null
} else { } else {
NoteFacets( NoteFacets(
q = null, q = q,
label = labelIds.toList().ifEmpty { null }, label = labelIds.toList().ifEmpty { null },
hasAttachment = if (hasAttachment) true else null, hasAttachment = if (hasAttachment) true else null,
createdAfter = null, createdAfter = null,
@@ -56,6 +61,7 @@ data class BoardFilters(
shared = if (sharedWithMe) SHARED_WITH_ME else null, shared = if (sharedWithMe) SHARED_WITH_ME else null,
) )
} }
}
fun toggleLabel(id: String) = copy(labelIds = if (id in labelIds) labelIds - id else labelIds + id) fun toggleLabel(id: String) = copy(labelIds = if (id in labelIds) labelIds - id else labelIds + id)
} }
@@ -83,20 +83,22 @@ data class BoardState(
*/ */
val editingSession: Long = 0, 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() val searching: Boolean get() = query.isNotBlank()
/** /** The main board only, as on the web, whose Filters panel is the main board's. */
* The main board only, and not while searching, as on the web: a search spans val filterable: Boolean get() = destination == Destination.Notes
* everything, so a filter over its hits would be a control that does nothing.
*/
val filterable: Boolean get() = destination == Destination.Notes && !searching
/** /**
* The whole board in one order, as on the web: not a search, another view, or a * 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. * 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 { try {
val destination = state.destination val destination = state.destination
val filters = state.filters 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) state.copy(notes = notes, loading = false, error = null)
} catch (e: Exception) { } catch (e: Exception) {
// Broad by intent: the board must render something for any // 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( private fun load(
destination: Destination, destination: Destination,
filters: BoardFilters, filters: BoardFilters,
): List<Note> = text: String,
when (destination) { ): List<Note> {
Destination.Notes -> core.listNotes(query(VIEW_NOTES, facets = filters.facets())) // Only the main board has filters; every board view takes the search text.
Destination.Archive -> core.listNotes(query(VIEW_ARCHIVE)) val searchOnly = BoardFilters().facets(text)
Destination.Trash -> core.listNotes(query(VIEW_TRASH)) 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 // 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() 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. * Reload the drawer's tags, and leave a lens whose tag no longer exists.
@@ -231,7 +240,15 @@ class BoardViewModel(
} }
fun search(text: String) { 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() searchJob?.cancel()
if (text.isBlank()) { if (text.isBlank()) {
@@ -247,7 +264,8 @@ class BoardViewModel(
state = state.copy(loading = true) state = state.copy(loading = true)
state = state =
try { 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) state.copy(notes = hits, loading = false, error = null)
} catch (e: Exception) { } catch (e: Exception) {
state.copy(loading = false, error = e.message ?: FALLBACK_ERROR) state.copy(loading = false, error = e.message ?: FALLBACK_ERROR)
@@ -569,13 +587,7 @@ class BoardViewModel(
try { try {
val updated = withContext(Dispatchers.IO) { block(core) } val updated = withContext(Dispatchers.IO) { block(core) }
val notes = val notes =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { load(state.destination, state.filters, state.query) }
if (state.searching) {
core.searchNotes(state.query)
} else {
load(state.destination, state.filters)
}
}
// On IO, not here: re-deriving the alarm reads every note // On IO, not here: re-deriving the alarm reads every note
// that carries a reminder, and this line runs on the main // that carries a reminder, and this line runs on the main
// thread — the coroutine is back from its withContext by now. // thread — the coroutine is back from its withContext by now.
@@ -20,12 +20,32 @@ class BoardFiltersTest {
assertEquals(setOf("a", "b"), facets.label?.toSet()) assertEquals(setOf("a", "b"), facets.label?.toSet())
assertEquals(true, facets.hasAttachment) assertEquals(true, facets.hasAttachment)
assertEquals("with_me", facets.shared) 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.q)
assertNull(facets.createdAfter) assertNull(facets.createdAfter)
assertNull(facets.createdBefore) 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 @Test
fun `a switch that is off is left out rather than sent as false`() { fun `a switch that is off is left out rather than sent as false`() {
val facets = requireNotNull(BoardFilters(labelIds = setOf("a")).facets()) val facets = requireNotNull(BoardFilters(labelIds = setOf("a")).facets())
-11
View File
@@ -171,17 +171,6 @@ impl Inkwell {
.map_err(CoreError::store) .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<Vec<Note>, 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. /// Notes carrying a reminder, soonest first.
/// ///
/// A dedicated call rather than a board `view`, because that is how the core /// A dedicated call rather than a board `view`, because that is how the core
-9
View File
@@ -515,15 +515,6 @@ pub fn titles(conn: &Connection) -> rusqlite::Result<Vec<TitleEntry>> {
.collect()) .collect())
} }
pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
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 ----------------------------------------------------------- // ---- notes: write -----------------------------------------------------------
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> { pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {