diff --git a/components/places/android/src/main/java/mozilla/appservices/places/PlacesConnection.kt b/components/places/android/src/main/java/mozilla/appservices/places/PlacesConnection.kt index 7d69d6aaa7..5e9c7d9b5b 100644 --- a/components/places/android/src/main/java/mozilla/appservices/places/PlacesConnection.kt +++ b/components/places/android/src/main/java/mozilla/appservices/places/PlacesConnection.kt @@ -262,6 +262,12 @@ open class PlacesReaderConnection internal constructor(conn: UniffiPlacesConnect } } + override fun searchFolders(query: String, limit: Int): List { + return readQueryCounters.measure { + this.conn.foldersSearch(query, limit) + } + } + override fun getRecentBookmarks(limit: Int): List { return readQueryCounters.measure { this.conn.bookmarksGetRecent(limit) diff --git a/components/places/src/ffi.rs b/components/places/src/ffi.rs index 39386398fa..8a9214ce73 100644 --- a/components/places/src/ffi.rs +++ b/components/places/src/ffi.rs @@ -535,6 +535,11 @@ impl PlacesConnection { }) } + #[handle_error(crate::Error)] + pub fn folders_search(&self, query: String, limit: i32) -> ApiResult> { + self.with_conn(|conn| bookmarks::fetch::search_folders(conn, query.as_str(), limit as u32)) + } + #[handle_error(crate::Error)] pub fn bookmarks_get_recent(&self, limit: i32) -> ApiResult> { self.with_conn(|conn| { diff --git a/components/places/src/places.udl b/components/places/src/places.udl index b626ef7f4e..c06fbaeb5a 100644 --- a/components/places/src/places.udl +++ b/components/places/src/places.udl @@ -190,6 +190,12 @@ interface PlacesConnection { [Throws=PlacesApiError] sequence bookmarks_search(string query, i32 limit); + /// Searches folders for a string in the folder title. + /// Matching folders are returned without any child information - ie, both `child_guids` + /// and `child_nodes` will be null. + [Throws=PlacesApiError] + sequence folders_search(string query, i32 limit); + // XXX - should return BookmarkData [Throws=PlacesApiError] sequence bookmarks_get_recent(i32 limit); diff --git a/components/places/src/storage/bookmarks/fetch.rs b/components/places/src/storage/bookmarks/fetch.rs index b5b090497f..7311f54e1e 100644 --- a/components/places/src/storage/bookmarks/fetch.rs +++ b/components/places/src/storage/bookmarks/fetch.rs @@ -359,6 +359,37 @@ pub fn recent_bookmarks(db: &PlacesDb, limit: u32) -> Result> .collect()) } +fn folder_from_row(row: &Row<'_>) -> Result { + Ok(Folder { + guid: row.get("guid")?, + parent_guid: row.get("parentGuid")?, + position: row.get("position")?, + date_added: row.get("dateAdded")?, + last_modified: row.get("lastModified")?, + title: row.get("title")?, + child_guids: None, + child_nodes: None, + }) +} + +pub fn search_folders(db: &PlacesDb, search: &str, limit: u32) -> Result> { + let scope = db.begin_interrupt_scope()?; + Ok(db + .query_rows_into_cached::, _, _, _, _>( + &SEARCH_FOLDERS_QUERY, + &[ + (":search", &search as &dyn rusqlite::ToSql), + (":limit", &limit), + ], + |row| -> Result<_> { + scope.err_if_interrupted()?; + folder_from_row(row) + }, + )? + .into_iter() + .collect()) +} + lazy_static::lazy_static! { pub static ref SEARCH_QUERY: String = format!( "SELECT @@ -413,6 +444,43 @@ lazy_static::lazy_static! { LIMIT :limit", bookmark_type = BookmarkType::Bookmark as u8 ); + + // We use AUTOCOMPLETE_MATCH for folders too - it's a little overkill, but we + // get some of the unicode handling etc for free. + pub static ref SEARCH_FOLDERS_QUERY: String = format!( + "SELECT + b.guid, + p.guid AS parentGuid, + b.position, + b.dateAdded, + b.lastModified, + -- Note we return null for titles with an empty string. + NULLIF(b.title, '') AS title + FROM moz_bookmarks b + JOIN moz_bookmarks p ON p.id = b.parent + WHERE b.type = {folder_type} + AND b.guid NOT IN ('{root_guid}', '{menu_guid}', '{mobile_guid}', '{toolbar_guid}', '{unfiled_guid}') + AND AUTOCOMPLETE_MATCH( + :search, '', IFNULL(b.title, ''), + NULL, + 0, -- visit_count + 0, -- typed + 1, -- bookmarked + NULL, -- open page count + {match_bhvr}, + {search_bhvr} + ) + LIMIT :limit", + folder_type = BookmarkType::Folder as u8, + match_bhvr = crate::match_impl::MatchBehavior::Anywhere as u32, + search_bhvr = crate::match_impl::SearchBehavior::BOOKMARK.bits(), + root_guid = BookmarkRootGuid::Root.as_str(), + menu_guid = BookmarkRootGuid::Menu.as_str(), + mobile_guid = BookmarkRootGuid::Mobile.as_str(), + toolbar_guid = BookmarkRootGuid::Toolbar.as_str(), + unfiled_guid = BookmarkRootGuid::Unfiled.as_str(), + + ); } #[cfg(test)] @@ -580,6 +648,56 @@ mod test { } Ok(()) } + + #[test] + fn test_search_folders() -> Result<()> { + let conns = new_mem_connections(); + insert_json_tree( + &conns.write, + json!({ + "guid": String::from(BookmarkRootGuid::Unfiled.as_str()), + "children": [ + { + "guid": "folder1_____", + "title": "my example folder", + "children": [ + { + "guid": "bookmark1___", + "url": "https://www.example1.com/", + "title": "example bookmark", + }, + { + "guid": "folder2_____", + "title": "another folder", + "children": [] + } + ] + } + ] + }), + ); + let mut folders = search_folders(&conns.read, "ample", 10)?; + folders.sort_by_key(|b| b.guid.as_str().to_string()); + assert_eq!(folders.len(), 1); + assert_eq!(folders[0].guid, "folder1_____"); + + let mut folders = search_folders(&conns.read, "older", 10)?; + folders.sort_by_key(|b| b.guid.as_str().to_string()); + assert_eq!(folders.len(), 2); + assert_eq!(folders[0].guid, "folder1_____"); + assert_eq!( + folders[0].parent_guid, + Some(BookmarkRootGuid::Unfiled.as_guid()) + ); + assert_eq!(folders[1].guid, "folder2_____"); + assert_eq!(folders[1].parent_guid, Some(SyncGuid::new("folder1_____"))); + + // check we never get the roots. + assert_eq!(search_folders(&conns.read, "", 10)?.len(), 2); + + Ok(()) + } + #[test] fn test_fetch_bookmark() -> Result<()> { let conns = new_mem_connections();