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.
*
* 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<String> = 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)
}
@@ -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<Note> =
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<Note> {
// 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.
@@ -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())
-11
View File
@@ -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<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.
///
/// 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())
}
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 -----------------------------------------------------------
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {