authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 15:52:18-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 16:11:25-07:00
log1a9bd5592f8f9bd6781eb91b4b2c383af90a4f9b
treea5efe67fb45480eef2f4f16a40229e06c2486721
parent5e3e8d16b21e71aabde09deeb19ccfa44505cb32
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: iOS searches as the desktop does, its scopes offered from a section

The field reads Search. sb_search ranks every notebook's pages together, title matches first, and marks the words in titles and snippets; from a section it narrows to This Section or This Notebook, starting at the section. Searches and the Tags Summary read the open sections the notebook already knows. Assisted-by: claude-opus-5.5

6 files changed, 251 insertions(+), 118 deletions(-)

apps/ios/Snowbound/Lists.swift+110-48
......@@ -817,7 +817,7 @@ final class PagesViewController: UITableViewController {
817817 private(set) var section: Section?
818818 private var items: [Item] = []
819819 private var selected: String?
820 private let search = SearchViewController.controller()
820 private lazy var search = SearchViewController.controller { [weak self] in self?.section }
821821 /// Opens a page of the section.
822822 var onOpen: ((Section, String) -> Void)?
823823
......@@ -1026,27 +1026,61 @@ final class PagesViewController: UITableViewController {
10261026 }
10271027}
10281028
1029/// A page `sb_library_search` found.
1029/// A page `sb_search` found.
10301030struct Found: Decodable {
1031 let notebook: Int
10311032 let section: String
10321033 let page: String
10331034 let title: String
1035 let inTitle: Bool
1036 let titleHits: [[Int]]
10341037 let snippet: String
1038 let snippetHits: [[Int]]
10351039}
10361040
1037/// Every open notebook's pages holding a query, grouped by notebook, as Notes searches
1038/// every folder.
1039final class SearchViewController: UITableViewController, UISearchResultsUpdating {
1040 private var found: [(notebook: Notebook, pages: [Found])] = []
1041/// The desktop's search over every open notebook's pages: title matches, then body matches,
1042/// each with its notebook and section. Searched from a section, it offers OneNote's scopes,
1043/// starting at This Section.
1044final class SearchViewController: UITableViewController, UISearchResultsUpdating, UISearchBarDelegate,
1045 UISearchControllerDelegate
1046{
1047 private enum Scope: Int, CaseIterable {
1048 case section, notebook, all
1049
1050 var title: String {
1051 switch self {
1052 case .section: "This Section"
1053 case .notebook: "This Notebook"
1054 case .all: "All Notebooks"
1055 }
1056 }
1057 }
1058
1059 /// The section searched from, which the scopes narrow to.
1060 private let place: () -> Section?
1061 private weak var search: UISearchController?
1062 private var found: [(found: Found, notebook: Notebook)] = []
10411063 private var query = ""
10421064 private var searching: DispatchWorkItem?
10431065
1044 /// A search bar whose results this shows.
1045 static func controller() -> UISearchController {
1046 let results = SearchViewController(style: .insetGrouped)
1066 private init(place: @escaping () -> Section?) {
1067 self.place = place
1068 super.init(style: .insetGrouped)
1069 }
1070
1071 required init?(coder: NSCoder) { fatalError() }
1072
1073 /// A search bar whose results this shows, with scopes where `place` gives the section
1074 /// it is searched from.
1075 static func controller(place: (() -> Section?)? = nil) -> UISearchController {
1076 let results = SearchViewController(place: place ?? { nil })
10471077 let search = UISearchController(searchResultsController: results)
1078 results.search = search
10481079 search.searchResultsUpdater = results
1049 search.searchBar.placeholder = "Search Notebooks"
1080 search.delegate = results
1081 search.searchBar.delegate = results
1082 search.searchBar.placeholder = "Search"
1083 if place != nil { search.searchBar.scopeButtonTitles = Scope.allCases.map(\.title) }
10501084 return search
10511085 }
10521086
......@@ -1055,10 +1089,25 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating
10551089 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "found")
10561090 }
10571091
1092 func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
1093 if let search { updateSearchResults(for: search) }
1094 }
1095
1096 /// A scope chosen lasts for the search; the next starts at This Section again.
1097 func didDismissSearchController(_ searchController: UISearchController) {
1098 searchController.searchBar.selectedScopeButtonIndex = Scope.section.rawValue
1099 }
1100
10581101 func updateSearchResults(for controller: UISearchController) {
10591102 searching?.cancel()
10601103 let query = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? ""
10611104 guard !query.isEmpty else { return show([], query: query) }
1105 let open = place()
1106 let scope = open == nil ? .all : Scope(rawValue: controller.searchBar.selectedScopeButtonIndex) ?? .all
1107 let notebooks = scope == .all ? Notebooks.all : open.map { [$0.notebook] } ?? []
1108 let searched = notebooks.filter { $0.handle != nil }
1109 let libraries = searched.map { Int(bitPattern: $0.handle) }
1110 let section = scope == .section ? open?.tab.path : nil
10621111 // Each keystroke waits for the next before the notebooks are read.
10631112 let work = DispatchWorkItem { [weak self] in
10641113 if self?.found.isEmpty == true {
......@@ -1066,30 +1115,21 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating
10661115 loading.text = "Searching…"
10671116 self?.contentUnavailableConfiguration = loading
10681117 }
1069 let notebooks = Notebooks.all.compactMap { notebook -> (Notebook, Int, Int, String?)? in
1070 guard let library = notebook.handle else { return nil }
1071 // Open sections are searched as their edits leave them.
1072 let open = Section.all.first { $0.notebook === notebook }
1073 return (notebook, Int(bitPattern: library), open.map { Int(bitPattern: $0.handle) } ?? 0, open?.tab.path)
1074 }
10751118 background({
1076 notebooks.map { notebook, library, open, path in
1077 let found = decode(
1078 [Found].self,
1079 sb_library_search(
1080 OpaquePointer(bitPattern: library), OpaquePointer(bitPattern: open), path, query))
1081 return (notebook: notebook, pages: found ?? [])
1082 }
1119 let pointers = libraries.map { OpaquePointer(bitPattern: $0) }
1120 return decode([Found].self, sb_search(pointers, pointers.count, section, query)) ?? []
10831121 }) { [weak self] found in
1084 guard controller.searchBar.text?.trimmingCharacters(in: .whitespaces) == query else { return }
1085 self?.show(found.filter { !$0.pages.isEmpty }, query: query)
1122 let now = controller.searchBar.text?.trimmingCharacters(in: .whitespaces)
1123 guard now == query, open == nil || controller.searchBar.selectedScopeButtonIndex == scope.rawValue
1124 else { return }
1125 self?.show(found.map { ($0, searched[$0.notebook]) }, query: query)
10861126 }
10871127 }
10881128 searching = work
10891129 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
10901130 }
10911131
1092 private func show(_ found: [(notebook: Notebook, pages: [Found])], query: String) {
1132 private func show(_ found: [(found: Found, notebook: Notebook)], query: String) {
10931133 self.found = found
10941134 self.query = query
10951135 tableView.reloadData()
......@@ -1097,49 +1137,71 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating
10971137 found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil
10981138 }
10991139
1100 override func numberOfSections(in tableView: UITableView) -> Int { found.count }
1140 /// The results in the title's group or the body's, as the desktop's headings split them.
1141 private func group(_ section: Int) -> ArraySlice<(found: Found, notebook: Notebook)> {
1142 let titled = found.prefix { $0.found.inTitle }
1143 return titled.isEmpty || section == 1 ? found.dropFirst(titled.count) : titled
1144 }
1145
1146 override func numberOfSections(in tableView: UITableView) -> Int {
1147 (found.first?.found.inTitle == true ? 1 : 0) + (found.last?.found.inTitle == false ? 1 : 0)
1148 }
11011149
11021150 override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
1103 found[section].notebook.name
1151 let pages = group(section)
1152 return "\(pages.first?.found.inTitle == true ? "Title" : "Body") contains: \(query) (\(pages.count))"
11041153 }
11051154
11061155 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
1107 found[section].pages.count
1156 group(section).count
1157 }
1158
1159 private func result(_ indexPath: IndexPath) -> (found: Found, notebook: Notebook) {
1160 let pages = group(indexPath.section)
1161 return pages[pages.startIndex + indexPath.row]
11081162 }
11091163
11101164 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
1111 let (notebook, pages) = found[indexPath.section]
1112 let page = pages[indexPath.row]
1165 let (page, notebook) = result(indexPath)
11131166 let cell = tableView.dequeueReusableCell(withIdentifier: "found", for: indexPath)
11141167 var content = UIListContentConfiguration.subtitleCell()
1115 content.text = page.title.isEmpty ? "Untitled Page" : page.title
1168 let font = UIFont.preferredFont(forTextStyle: .body)
1169 content.attributedText =
1170 page.title.isEmpty
1171 ? NSAttributedString(string: "Untitled Page", attributes: [.font: font])
1172 : marked(page.title, page.titleHits, font: font, color: .label)
11161173 let tab = notebook.tabs.first { $0.path == page.section }
11171174 content.image = UIImage(systemName: "rectangle.portrait.fill")
11181175 content.imageProperties.tintColor = tab?.uiColor ?? .systemGray
1119 content.secondaryAttributedText = highlighted(
1120 [tab?.name ?? "", page.snippet].filter { !$0.isEmpty }.joined(separator: " · "))
1121 content.secondaryTextProperties.numberOfLines = 2
1176 // Where the page is, as the desktop names it: notebook, groups and section.
1177 let groups: [String] = tab?.group.split(separator: "/").map(String.init) ?? []
1178 let place = ([notebook.name] + groups + [tab?.name ?? ""]).filter { !$0.isEmpty }.joined(separator: " › ")
1179 let detail = NSMutableAttributedString(
1180 string: place + "\n",
1181 attributes: [.font: UIFont.preferredFont(forTextStyle: .footnote), .foregroundColor: UIColor.secondaryLabel])
1182 detail.append(
1183 marked(
1184 page.snippet, page.snippetHits, font: .preferredFont(forTextStyle: .subheadline),
1185 color: .secondaryLabel))
1186 content.secondaryAttributedText = detail
1187 content.secondaryTextProperties.numberOfLines = 3
11221188 cell.contentConfiguration = content
11231189 return cell
11241190 }
11251191
1126 /// `text` with the query in bold where it occurs.
1127 private func highlighted(_ text: String) -> NSAttributedString {
1128 let font = UIFont.preferredFont(forTextStyle: .subheadline)
1129 let attributed = NSMutableAttributedString(
1130 string: text, attributes: [.font: font, .foregroundColor: UIColor.secondaryLabel])
1131 var range = text.startIndex..<text.endIndex
1132 while let match = text.range(of: query, options: .caseInsensitive, range: range) {
1133 attributed.addAttributes(
1134 [.font: font.withTraits(.traitBold), .foregroundColor: UIColor.label], range: NSRange(match, in: text))
1135 range = match.upperBound..<text.endIndex
1192 /// `text` with `hits`, UTF-16 ranges, marked in OneNote's yellow as the desktop marks them.
1193 private func marked(_ text: String, _ hits: [[Int]], font: UIFont, color: UIColor) -> NSAttributedString {
1194 let marked = NSMutableAttributedString(string: text, attributes: [.font: font, .foregroundColor: color])
1195 for hit in hits where hit.count == 2 && hit[1] <= marked.length {
1196 marked.addAttributes(
1197 [.backgroundColor: UIColor(red: 1, green: 0.82, blue: 0, alpha: 0.45), .foregroundColor: UIColor.label],
1198 range: NSRange(location: hit[0], length: hit[1] - hit[0]))
11361199 }
1137 return attributed
1200 return marked
11381201 }
11391202
11401203 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
1141 let (notebook, pages) = found[indexPath.section]
1142 let page = pages[indexPath.row]
1204 let (page, notebook) = result(indexPath)
11431205 (view.window?.windowScene?.delegate as? SceneDelegate)?
11441206 .open(page.section, of: notebook, page: page.page, reveal: .text(query))
11451207 }
apps/ios/Snowbound/Tags.swift+2-6
......@@ -133,12 +133,8 @@ final class TagsViewController: UITableViewController {
133133 loading.text = "Finding Tags…"
134134 contentUnavailableConfiguration = loading
135135 guard let library = notebook.handle else { return }
136 let open = Section.all.first { $0.notebook === notebook }
137 let (pointer, section, path) = (Int(bitPattern: library), open.map { Int(bitPattern: $0.handle) } ?? 0, open?.tab.path)
138 background({
139 decode([Tagged].self, sb_library_tagged(OpaquePointer(bitPattern: pointer), OpaquePointer(bitPattern: section), path))
140 ?? []
141 }) { [weak self] tagged in
136 let pointer = Int(bitPattern: library)
137 background({ decode([Tagged].self, sb_library_tagged(OpaquePointer(bitPattern: pointer))) ?? [] }) { [weak self] tagged in
142138 self?.all = tagged
143139 self?.show()
144140 }
crates/canvas/src/search.rs+11-6
......@@ -329,6 +329,13 @@ pub struct Found {
329329 pub snippet_hits: Vec<Range<usize>>,
330330}
331331
332impl Found {
333 /// Where OneNote lists the page: title matches first, then the most recently modified.
334 pub fn rank(&self) -> (bool, std::cmp::Reverse<u64>) {
335 (!self.in_title, std::cmp::Reverse(self.modified))
336 }
337}
338
332339/// Pages of any number of sections, searchable together.
333340#[derive(Default)]
334341pub struct Index {
......@@ -379,7 +386,7 @@ impl Index {
379386 if query.is_empty() {
380387 return Vec::new();
381388 }
382 let mut found: Vec<(usize, &Entry, bool)> = self
389 let mut found: Vec<Found> = self
383390 .entries
384391 .iter()
385392 .enumerate()
......@@ -393,10 +400,6 @@ impl Index {
393400 });
394401 matches.then_some((order, entry, in_title))
395402 })
396 .collect();
397 found.sort_by_key(|(_, entry, in_title)| (!in_title, std::cmp::Reverse(entry.modified)));
398 found
399 .into_iter()
400403 .map(|(order, entry, in_title)| {
401404 let (snippet, snippet_hits) = snippet(entry, query);
402405 Found {
......@@ -411,7 +414,9 @@ impl Index {
411414 snippet_hits,
412415 }
413416 })
414 .collect()
417 .collect();
418 found.sort_by_key(Found::rank);
419 found
415420 }
416421
417422 /// The tagged paragraphs of the pages whose section and page `scope` accepts, in page
crates/mobile/include/snowbound.h+2-2
......@@ -32,8 +32,8 @@ char *sb_library_sections(const Library *library);
3232int32_t sb_library_color(const Library *library);
3333char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author,
3434 const char *date, const char *time);
35char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query);
36char *sb_library_tagged(const Library *library, const Section *open, const char *path);
35char *sb_search(const Library *const *libraries, size_t count, const char *section, const char *query);
36char *sb_library_tagged(const Library *library);
3737bool sb_library_unlock(const Library *library, const char *path, const char *password);
3838void sb_library_lock_all(const Library *library);
3939char *sb_library_sync_status(const Library *library);
crates/mobile/src/library.rs+85-52
......@@ -993,37 +993,73 @@ const CHANGED: u32 = 2;
993993const REJECTED: u32 = 4;
994994
995995#[derive(serde::Serialize)]
996#[serde(rename_all = "camelCase")]
996997pub(crate) struct Found {
998 /// Which of the notebooks searched holds the page.
999 pub(crate) notebook: usize,
9971000 /// The section's catalog path.
9981001 pub(crate) section: String,
9991002 pub(crate) page: String,
10001003 pub(crate) title: String,
1004 /// Every word is in the title, as OneNote's "Title contains" lists it.
1005 pub(crate) in_title: bool,
1006 /// Where the words match `title`, as UTF-16 `[start, end]`.
1007 pub(crate) title_hits: Vec<[usize; 2]>,
10011008 /// The line around the first match.
10021009 pub(crate) snippet: String,
1010 pub(crate) snippet_hits: Vec<[usize; 2]>,
1011}
1012
1013/// Pages of `libraries` holding every word of `query` in their title or text, as the desktop
1014/// searches and lists them; only of the section at catalog `section` where given.
1015pub(crate) fn search(
1016 libraries: &[&Library],
1017 section: Option<&str>,
1018 query: &str,
1019) -> Result<Vec<Found>> {
1020 let query = Query::new(query);
1021 let mut found = Vec::new();
1022 for (notebook, library) in libraries.iter().enumerate() {
1023 library.indexed(|index| {
1024 let scope = |key: &str| section.is_none_or(|section| key == section);
1025 found.extend(
1026 index
1027 .search(&query, scope)
1028 .into_iter()
1029 .map(|found| (notebook, found)),
1030 );
1031 })?;
1032 }
1033 found.sort_by_key(|(_, found)| found.rank());
1034 Ok(found
1035 .into_iter()
1036 .map(|(notebook, found)| Found {
1037 notebook,
1038 page: found.space.to_string(),
1039 title: one_line(&found.title),
1040 in_title: found.in_title,
1041 title_hits: utf16(&found.title, found.title_hits),
1042 snippet_hits: utf16(&found.snippet, found.snippet_hits),
1043 snippet: found.snippet,
1044 section: found.section,
1045 })
1046 .collect())
10031047}
10041048
1005impl Library {
1006 /// Pages of the notebook holding every word of `query` in their title or text, as the
1007 /// desktop searches.
1008 pub(crate) fn search(&self, open: Option<(&str, &Section)>, query: &str) -> Result<Vec<Found>> {
1009 self.indexed(open, |index| {
1010 index
1011 .search(&Query::new(query), |_| true)
1012 .into_iter()
1013 .map(|found| Found {
1014 section: found.section,
1015 page: found.space.to_string(),
1016 title: one_line(&found.title),
1017 snippet: found.snippet,
1018 })
1019 .collect()
1020 })
1021 }
1049/// Byte ranges of `text` as UTF-16 `[start, end]`, as `NSRange` counts.
1050fn utf16(text: &str, ranges: Vec<std::ops::Range<usize>>) -> Vec<[usize; 2]> {
1051 let units = |end: usize| text[..end].encode_utf16().count();
1052 ranges
1053 .into_iter()
1054 .map(|range| [units(range.start), units(range.end)])
1055 .collect()
1056}
10221057
1058impl Library {
10231059 /// The notebook's tagged paragraphs in page order, as OneNote's Tags Summary lists
10241060 /// them: once for each of their tags.
1025 pub(crate) fn tagged(&self, open: Option<(&str, &Section)>) -> Result<Vec<Tag>> {
1026 self.indexed(open, |index| {
1061 pub(crate) fn tagged(&self) -> Result<Vec<Tag>> {
1062 self.indexed(|index| {
10271063 index
10281064 .tagged(|_| true)
10291065 .into_iter()
......@@ -1041,13 +1077,10 @@ impl Library {
10411077 })
10421078 }
10431079
1044 /// `read` over the notebook's pages: the open section as its edits leave it, others as
1045 /// stored, read again when their file changed.
1046 fn indexed<T>(
1047 &self,
1048 open: Option<(&str, &Section)>,
1049 read: impl FnOnce(&Index) -> T,
1050 ) -> Result<T> {
1080 /// `read` over the notebook's pages: the sections open as their edits leave them, others
1081 /// as stored, read again when their file changed.
1082 fn indexed<T>(&self, read: impl FnOnce(&Index) -> T) -> Result<T> {
1083 let open = self.open_sections();
10511084 let tabs: Vec<String> = self
10521085 .tabs()?
10531086 .into_iter()
......@@ -1057,9 +1090,9 @@ impl Library {
10571090 let mut indexed = self.index.lock().unwrap_or_else(|error| error.into_inner());
10581091 let (checked, index) = &mut *indexed;
10591092 for path in &tabs {
1060 match open.filter(|(open, _)| open == path) {
1061 Some((_, section)) => {
1062 let section = &section.shared.section;
1093 match open.iter().find(|(open, _)| open == path) {
1094 Some((_, shared)) => {
1095 let section = &shared.section;
10631096 let mut entries = Vec::new();
10641097 for (space, ..) in section.pages()? {
10651098 let modified = index.get(path, space).map_or(0, |entry| entry.modified);
......@@ -1444,39 +1477,39 @@ pub unsafe extern "C" fn sb_library_new_section(
14441477 .map_or(std::ptr::null_mut(), owned)
14451478}
14461479
1447/// Pages of the notebook whose title or text holds `query`, as JSON: each with `section`
1448/// (a catalog path), `page`, `title` and `snippet`. `open` is the section being edited, at
1449/// catalog path `path`, or null. Reads the notebook's sections, so call it off the main
1450/// thread.
1480/// Pages of the `count` notebooks at `libraries` whose title or text holds `query`, title
1481/// matches first and then the most recently changed, as JSON: each with `notebook` (its
1482/// place in `libraries`), `section` (a catalog path), `page`, `title`, `inTitle`,
1483/// `snippet`, and `titleHits` and `snippetHits`, UTF-16 `[start, end]`. Only the section
1484/// at catalog path `section` where non-null. Open sections are searched as their edits leave
1485/// them; reads the notebooks' sections, so call it off the main thread.
14511486///
14521487/// # Safety
1453/// `query` and a non-null `path` are NUL-terminated UTF-8.
1488/// `libraries` holds `count` libraries; `query` and a non-null `section` are NUL-terminated
1489/// UTF-8.
14541490#[unsafe(no_mangle)]
1455pub unsafe extern "C" fn sb_library_search(
1456 library: &Library,
1457 open: Option<&Section>,
1458 path: *const c_char,
1491pub unsafe extern "C" fn sb_search(
1492 libraries: *const &Library,
1493 count: usize,
1494 section: *const c_char,
14591495 query: *const c_char,
14601496) -> *mut c_char {
1461 let path = optional(path).unwrap_or_default();
1462 json(library.search(open.map(|section| (path.as_str(), section)), &string(query)))
1497 // SAFETY: the caller passes `count` libraries.
1498 let libraries = unsafe { std::slice::from_raw_parts(libraries, count) };
1499 json(search(
1500 libraries,
1501 optional(section).as_deref(),
1502 &string(query),
1503 ))
14631504}
14641505
14651506/// The notebook's tagged paragraphs in page order as JSON, once for each of their tags:
14661507/// each with `section`, `page`, `title`, `paragraph`, `name`, `shape` (the tag's symbol, 0
1467/// for a highlighting tag), `checked` and `text`. `open` and `path` as `sb_library_search`
1468/// takes them; reads the notebook's sections, so call it off the main thread.
1469///
1470/// # Safety
1471/// A non-null `path` is NUL-terminated UTF-8.
1508/// for a highlighting tag), `checked` and `text`. Open sections are read as their edits
1509/// leave them; reads the notebook's sections, so call it off the main thread.
14721510#[unsafe(no_mangle)]
1473pub unsafe extern "C" fn sb_library_tagged(
1474 library: &Library,
1475 open: Option<&Section>,
1476 path: *const c_char,
1477) -> *mut c_char {
1478 let path = optional(path).unwrap_or_default();
1479 json(library.tagged(open.map(|section| (path.as_str(), section))))
1511pub extern "C" fn sb_library_tagged(library: &Library) -> *mut c_char {
1512 json(library.tagged())
14801513}
14811514
14821515/// Unlocks the password-protected section at catalog `path` with `password`, as OneNote's
crates/mobile/src/tests.rs+41-4
......@@ -403,15 +403,52 @@ fn search_finds_pages_by_title_and_text_across_sections() {
403403 true,
404404 )
405405 .unwrap();
406 let found = library.search(None, "collapsed PARENT").unwrap();
406 let found = library::search(&[&library], None, "collapsed PARENT").unwrap();
407407 assert!(!found.is_empty());
408408 assert!(found[0].snippet.to_lowercase().contains("collapsed parent"));
409 let [start, end] = found[0].snippet_hits[0];
410 let units: Vec<u16> = found[0].snippet.encode_utf16().collect();
411 assert_eq!(
412 String::from_utf16(&units[start..end])
413 .unwrap()
414 .to_lowercase(),
415 "collapsed"
416 );
409417 assert!(
410 library
411 .search(None, "no such words anywhere")
418 library::search(&[&library], None, "no such words anywhere")
412419 .unwrap()
413420 .is_empty()
414421 );
422 let section = &found[0].section;
423 assert!(
424 library::search(&[&library], Some(section), "collapsed parent")
425 .unwrap()
426 .iter()
427 .all(|found| found.section == *section)
428 );
429}
430
431#[test]
432fn search_lists_title_matches_of_every_notebook_first() {
433 let directory = tempfile::tempdir().unwrap();
434 let personal = Library::open(
435 &corpus("search/notebook"),
436 &directory.path().join("a"),
437 true,
438 )
439 .unwrap();
440 let other = Library::open(
441 &corpus("m6/native-features-01/notebook"),
442 &directory.path().join("b"),
443 true,
444 )
445 .unwrap();
446 let found = library::search(&[&other, &personal], None, "tom").unwrap();
447 assert_eq!(found[0].title, "Tomatoes");
448 assert_eq!(found[0].notebook, 1);
449 assert_eq!(found[0].title_hits, [[0, 3]]);
450 assert!(found[1..].iter().all(|found| !found.in_title));
451 assert_eq!(found.len(), 5);
415452}
416453
417454#[test]
......@@ -826,7 +863,7 @@ fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() {
826863 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
827864 let path = file.to_string_lossy().into_owned();
828865 let section = open(&library, &path);
829 let tagged = library.tagged(Some((&path, &section))).unwrap();
866 let tagged = library.tagged().unwrap();
830867 assert!(tagged.len() >= 9, "{}", tagged.len());
831868 assert!(
832869 tagged