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
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:
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user