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 {...@@ -817,7 +817,7 @@ final class PagesViewController: UITableViewController {
817 private(set) var section: Section?817 private(set) var section: Section?
818 private var items: [Item] = []818 private var items: [Item] = []
819 private var selected: String?819 private var selected: String?
820 private let search = SearchViewController.controller()820 private lazy var search = SearchViewController.controller { [weak self] in self?.section }
821 /// Opens a page of the section.821 /// Opens a page of the section.
822 var onOpen: ((Section, String) -> Void)?822 var onOpen: ((Section, String) -> Void)?
823823
...@@ -1026,27 +1026,61 @@ final class PagesViewController: UITableViewController {...@@ -1026,27 +1026,61 @@ final class PagesViewController: UITableViewController {
1026 }1026 }
1027}1027}
10281028
1029/// A page `sb_library_search` found.1029/// A page `sb_search` found.
1030struct Found: Decodable {1030struct Found: Decodable {
1031 let notebook: Int
1031 let section: String1032 let section: String
1032 let page: String1033 let page: String
1033 let title: String1034 let title: String
1035 let inTitle: Bool
1036 let titleHits: [[Int]]
1034 let snippet: String1037 let snippet: String
1038 let snippetHits: [[Int]]
1035}1039}
10361040
1037/// Every open notebook's pages holding a query, grouped by notebook, as Notes searches1041/// The desktop's search over every open notebook's pages: title matches, then body matches,
1038/// every folder.1042/// each with its notebook and section. Searched from a section, it offers OneNote's scopes,
1039final class SearchViewController: UITableViewController, UISearchResultsUpdating {1043/// starting at This Section.
1040 private var found: [(notebook: Notebook, pages: [Found])] = []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)] = []
1041 private var query = ""1063 private var query = ""
1042 private var searching: DispatchWorkItem?1064 private var searching: DispatchWorkItem?
10431065
1044 /// A search bar whose results this shows.1066 private init(place: @escaping () -> Section?) {
1045 static func controller() -> UISearchController {1067 self.place = place
1046 let results = SearchViewController(style: .insetGrouped)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 })
1047 let search = UISearchController(searchResultsController: results)1077 let search = UISearchController(searchResultsController: results)
1078 results.search = search
1048 search.searchResultsUpdater = results1079 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) }
1050 return search1084 return search
1051 }1085 }
10521086
...@@ -1055,10 +1089,25 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating...@@ -1055,10 +1089,25 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating
1055 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "found")1089 tableView.register(UITableViewCell.self, forCellReuseIdentifier: "found")
1056 }1090 }
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
1058 func updateSearchResults(for controller: UISearchController) {1101 func updateSearchResults(for controller: UISearchController) {
1059 searching?.cancel()1102 searching?.cancel()
1060 let query = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? ""1103 let query = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? ""
1061 guard !query.isEmpty else { return show([], query: query) }1104 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
1062 // Each keystroke waits for the next before the notebooks are read.1111 // Each keystroke waits for the next before the notebooks are read.
1063 let work = DispatchWorkItem { [weak self] in1112 let work = DispatchWorkItem { [weak self] in
1064 if self?.found.isEmpty == true {1113 if self?.found.isEmpty == true {
...@@ -1066,30 +1115,21 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating...@@ -1066,30 +1115,21 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating
1066 loading.text = "Searching…"1115 loading.text = "Searching…"
1067 self?.contentUnavailableConfiguration = loading1116 self?.contentUnavailableConfiguration = loading
1068 }1117 }
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 }
1075 background({1118 background({
1076 notebooks.map { notebook, library, open, path in1119 let pointers = libraries.map { OpaquePointer(bitPattern: $0) }
1077 let found = decode(1120 return decode([Found].self, sb_search(pointers, pointers.count, section, query)) ?? []
1078 [Found].self,
1079 sb_library_search(
1080 OpaquePointer(bitPattern: library), OpaquePointer(bitPattern: open), path, query))
1081 return (notebook: notebook, pages: found ?? [])
1082 }
1083 }) { [weak self] found in1121 }) { [weak self] found in
1084 guard controller.searchBar.text?.trimmingCharacters(in: .whitespaces) == query else { return }1122 let now = controller.searchBar.text?.trimmingCharacters(in: .whitespaces)
1085 self?.show(found.filter { !$0.pages.isEmpty }, query: query)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)
1086 }1126 }
1087 }1127 }
1088 searching = work1128 searching = work
1089 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)1129 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
1090 }1130 }
10911131
1092 private func show(_ found: [(notebook: Notebook, pages: [Found])], query: String) {1132 private func show(_ found: [(found: Found, notebook: Notebook)], query: String) {
1093 self.found = found1133 self.found = found
1094 self.query = query1134 self.query = query
1095 tableView.reloadData()1135 tableView.reloadData()
...@@ -1097,49 +1137,71 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating...@@ -1097,49 +1137,71 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating
1097 found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil1137 found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil
1098 }1138 }
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
1102 override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {1150 override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
1103 found[section].notebook.name1151 let pages = group(section)
1152 return "\(pages.first?.found.inTitle == true ? "Title" : "Body") contains: \(query) (\(pages.count))"
1104 }1153 }
11051154
1106 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {1155 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
1107 found[section].pages.count1156 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]
1108 }1162 }
11091163
1110 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {1164 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
1111 let (notebook, pages) = found[indexPath.section]1165 let (page, notebook) = result(indexPath)
1112 let page = pages[indexPath.row]
1113 let cell = tableView.dequeueReusableCell(withIdentifier: "found", for: indexPath)1166 let cell = tableView.dequeueReusableCell(withIdentifier: "found", for: indexPath)
1114 var content = UIListContentConfiguration.subtitleCell()1167 var content = UIListContentConfiguration.subtitleCell()
1115 content.text = page.title.isEmpty ? "Untitled Page" : page.title1168 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)
1116 let tab = notebook.tabs.first { $0.path == page.section }1173 let tab = notebook.tabs.first { $0.path == page.section }
1117 content.image = UIImage(systemName: "rectangle.portrait.fill")1174 content.image = UIImage(systemName: "rectangle.portrait.fill")
1118 content.imageProperties.tintColor = tab?.uiColor ?? .systemGray1175 content.imageProperties.tintColor = tab?.uiColor ?? .systemGray
1119 content.secondaryAttributedText = highlighted(1176 // Where the page is, as the desktop names it: notebook, groups and section.
1120 [tab?.name ?? "", page.snippet].filter { !$0.isEmpty }.joined(separator: " · "))1177 let groups: [String] = tab?.group.split(separator: "/").map(String.init) ?? []
1121 content.secondaryTextProperties.numberOfLines = 21178 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
1122 cell.contentConfiguration = content1188 cell.contentConfiguration = content
1123 return cell1189 return cell
1124 }1190 }
11251191
1126 /// `text` with the query in bold where it occurs.1192 /// `text` with `hits`, UTF-16 ranges, marked in OneNote's yellow as the desktop marks them.
1127 private func highlighted(_ text: String) -> NSAttributedString {1193 private func marked(_ text: String, _ hits: [[Int]], font: UIFont, color: UIColor) -> NSAttributedString {
1128 let font = UIFont.preferredFont(forTextStyle: .subheadline)1194 let marked = NSMutableAttributedString(string: text, attributes: [.font: font, .foregroundColor: color])
1129 let attributed = NSMutableAttributedString(1195 for hit in hits where hit.count == 2 && hit[1] <= marked.length {
1130 string: text, attributes: [.font: font, .foregroundColor: UIColor.secondaryLabel])1196 marked.addAttributes(
1131 var range = text.startIndex..<text.endIndex1197 [.backgroundColor: UIColor(red: 1, green: 0.82, blue: 0, alpha: 0.45), .foregroundColor: UIColor.label],
1132 while let match = text.range(of: query, options: .caseInsensitive, range: range) {1198 range: NSRange(location: hit[0], length: hit[1] - hit[0]))
1133 attributed.addAttributes(
1134 [.font: font.withTraits(.traitBold), .foregroundColor: UIColor.label], range: NSRange(match, in: text))
1135 range = match.upperBound..<text.endIndex
1136 }1199 }
1137 return attributed1200 return marked
1138 }1201 }
11391202
1140 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {1203 override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
1141 let (notebook, pages) = found[indexPath.section]1204 let (page, notebook) = result(indexPath)
1142 let page = pages[indexPath.row]
1143 (view.window?.windowScene?.delegate as? SceneDelegate)?1205 (view.window?.windowScene?.delegate as? SceneDelegate)?
1144 .open(page.section, of: notebook, page: page.page, reveal: .text(query))1206 .open(page.section, of: notebook, page: page.page, reveal: .text(query))
1145 }1207 }
apps/ios/Snowbound/Tags.swift+2-6
...@@ -133,12 +133,8 @@ final class TagsViewController: UITableViewController {...@@ -133,12 +133,8 @@ final class TagsViewController: UITableViewController {
133 loading.text = "Finding Tags…"133 loading.text = "Finding Tags…"
134 contentUnavailableConfiguration = loading134 contentUnavailableConfiguration = loading
135 guard let library = notebook.handle else { return }135 guard let library = notebook.handle else { return }
136 let open = Section.all.first { $0.notebook === notebook }136 let pointer = Int(bitPattern: library)
137 let (pointer, section, path) = (Int(bitPattern: library), open.map { Int(bitPattern: $0.handle) } ?? 0, open?.tab.path)137 background({ decode([Tagged].self, sb_library_tagged(OpaquePointer(bitPattern: pointer))) ?? [] }) { [weak self] tagged in
138 background({
139 decode([Tagged].self, sb_library_tagged(OpaquePointer(bitPattern: pointer), OpaquePointer(bitPattern: section), path))
140 ?? []
141 }) { [weak self] tagged in
142 self?.all = tagged138 self?.all = tagged
143 self?.show()139 self?.show()
144 }140 }
crates/canvas/src/search.rs+11-6
...@@ -329,6 +329,13 @@ pub struct Found {...@@ -329,6 +329,13 @@ pub struct Found {
329 pub snippet_hits: Vec<Range<usize>>,329 pub snippet_hits: Vec<Range<usize>>,
330}330}
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
332/// Pages of any number of sections, searchable together.339/// Pages of any number of sections, searchable together.
333#[derive(Default)]340#[derive(Default)]
334pub struct Index {341pub struct Index {
...@@ -379,7 +386,7 @@ impl Index {...@@ -379,7 +386,7 @@ impl Index {
379 if query.is_empty() {386 if query.is_empty() {
380 return Vec::new();387 return Vec::new();
381 }388 }
382 let mut found: Vec<(usize, &Entry, bool)> = self389 let mut found: Vec<Found> = self
383 .entries390 .entries
384 .iter()391 .iter()
385 .enumerate()392 .enumerate()
...@@ -393,10 +400,6 @@ impl Index {...@@ -393,10 +400,6 @@ impl Index {
393 });400 });
394 matches.then_some((order, entry, in_title))401 matches.then_some((order, entry, in_title))
395 })402 })
396 .collect();
397 found.sort_by_key(|(_, entry, in_title)| (!in_title, std::cmp::Reverse(entry.modified)));
398 found
399 .into_iter()
400 .map(|(order, entry, in_title)| {403 .map(|(order, entry, in_title)| {
401 let (snippet, snippet_hits) = snippet(entry, query);404 let (snippet, snippet_hits) = snippet(entry, query);
402 Found {405 Found {
...@@ -411,7 +414,9 @@ impl Index {...@@ -411,7 +414,9 @@ impl Index {
411 snippet_hits,414 snippet_hits,
412 }415 }
413 })416 })
414 .collect()417 .collect();
418 found.sort_by_key(Found::rank);
419 found
415 }420 }
416421
417 /// The tagged paragraphs of the pages whose section and page `scope` accepts, in page422 /// 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);...@@ -32,8 +32,8 @@ char *sb_library_sections(const Library *library);
32int32_t sb_library_color(const Library *library);32int32_t sb_library_color(const Library *library);
33char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author,33char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author,
34 const char *date, const char *time);34 const char *date, const char *time);
35char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query);35char *sb_search(const Library *const *libraries, size_t count, const char *section, const char *query);
36char *sb_library_tagged(const Library *library, const Section *open, const char *path);36char *sb_library_tagged(const Library *library);
37bool sb_library_unlock(const Library *library, const char *path, const char *password);37bool sb_library_unlock(const Library *library, const char *path, const char *password);
38void sb_library_lock_all(const Library *library);38void sb_library_lock_all(const Library *library);
39char *sb_library_sync_status(const Library *library);39char *sb_library_sync_status(const Library *library);
crates/mobile/src/library.rs+85-52
...@@ -993,37 +993,73 @@ const CHANGED: u32 = 2;...@@ -993,37 +993,73 @@ const CHANGED: u32 = 2;
993const REJECTED: u32 = 4;993const REJECTED: u32 = 4;
994994
995#[derive(serde::Serialize)]995#[derive(serde::Serialize)]
996#[serde(rename_all = "camelCase")]
996pub(crate) struct Found {997pub(crate) struct Found {
998 /// Which of the notebooks searched holds the page.
999 pub(crate) notebook: usize,
997 /// The section's catalog path.1000 /// The section's catalog path.
998 pub(crate) section: String,1001 pub(crate) section: String,
999 pub(crate) page: String,1002 pub(crate) page: String,
1000 pub(crate) title: String,1003 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]>,
1001 /// The line around the first match.1008 /// The line around the first match.
1002 pub(crate) snippet: String,1009 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())
1003}1047}
10041048
1005impl Library {1049/// Byte ranges of `text` as UTF-16 `[start, end]`, as `NSRange` counts.
1006 /// Pages of the notebook holding every word of `query` in their title or text, as the1050fn utf16(text: &str, ranges: Vec<std::ops::Range<usize>>) -> Vec<[usize; 2]> {
1007 /// desktop searches.1051 let units = |end: usize| text[..end].encode_utf16().count();
1008 pub(crate) fn search(&self, open: Option<(&str, &Section)>, query: &str) -> Result<Vec<Found>> {1052 ranges
1009 self.indexed(open, |index| {1053 .into_iter()
1010 index1054 .map(|range| [units(range.start), units(range.end)])
1011 .search(&Query::new(query), |_| true)1055 .collect()
1012 .into_iter()1056}
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 }
10221057
1058impl Library {
1023 /// The notebook's tagged paragraphs in page order, as OneNote's Tags Summary lists1059 /// The notebook's tagged paragraphs in page order, as OneNote's Tags Summary lists
1024 /// them: once for each of their tags.1060 /// them: once for each of their tags.
1025 pub(crate) fn tagged(&self, open: Option<(&str, &Section)>) -> Result<Vec<Tag>> {1061 pub(crate) fn tagged(&self) -> Result<Vec<Tag>> {
1026 self.indexed(open, |index| {1062 self.indexed(|index| {
1027 index1063 index
1028 .tagged(|_| true)1064 .tagged(|_| true)
1029 .into_iter()1065 .into_iter()
...@@ -1041,13 +1077,10 @@ impl Library {...@@ -1041,13 +1077,10 @@ impl Library {
1041 })1077 })
1042 }1078 }
10431079
1044 /// `read` over the notebook's pages: the open section as its edits leave it, others as1080 /// `read` over the notebook's pages: the sections open as their edits leave them, others
1045 /// stored, read again when their file changed.1081 /// as stored, read again when their file changed.
1046 fn indexed<T>(1082 fn indexed<T>(&self, read: impl FnOnce(&Index) -> T) -> Result<T> {
1047 &self,1083 let open = self.open_sections();
1048 open: Option<(&str, &Section)>,
1049 read: impl FnOnce(&Index) -> T,
1050 ) -> Result<T> {
1051 let tabs: Vec<String> = self1084 let tabs: Vec<String> = self
1052 .tabs()?1085 .tabs()?
1053 .into_iter()1086 .into_iter()
...@@ -1057,9 +1090,9 @@ impl Library {...@@ -1057,9 +1090,9 @@ impl Library {
1057 let mut indexed = self.index.lock().unwrap_or_else(|error| error.into_inner());1090 let mut indexed = self.index.lock().unwrap_or_else(|error| error.into_inner());
1058 let (checked, index) = &mut *indexed;1091 let (checked, index) = &mut *indexed;
1059 for path in &tabs {1092 for path in &tabs {
1060 match open.filter(|(open, _)| open == path) {1093 match open.iter().find(|(open, _)| open == path) {
1061 Some((_, section)) => {1094 Some((_, shared)) => {
1062 let section = &section.shared.section;1095 let section = &shared.section;
1063 let mut entries = Vec::new();1096 let mut entries = Vec::new();
1064 for (space, ..) in section.pages()? {1097 for (space, ..) in section.pages()? {
1065 let modified = index.get(path, space).map_or(0, |entry| entry.modified);1098 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(...@@ -1444,39 +1477,39 @@ pub unsafe extern "C" fn sb_library_new_section(
1444 .map_or(std::ptr::null_mut(), owned)1477 .map_or(std::ptr::null_mut(), owned)
1445}1478}
14461479
1447/// Pages of the notebook whose title or text holds `query`, as JSON: each with `section`1480/// Pages of the `count` notebooks at `libraries` whose title or text holds `query`, title
1448/// (a catalog path), `page`, `title` and `snippet`. `open` is the section being edited, at1481/// matches first and then the most recently changed, as JSON: each with `notebook` (its
1449/// catalog path `path`, or null. Reads the notebook's sections, so call it off the main1482/// place in `libraries`), `section` (a catalog path), `page`, `title`, `inTitle`,
1450/// thread.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.
1451///1486///
1452/// # Safety1487/// # 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.
1454#[unsafe(no_mangle)]1490#[unsafe(no_mangle)]
1455pub unsafe extern "C" fn sb_library_search(1491pub unsafe extern "C" fn sb_search(
1456 library: &Library,1492 libraries: *const &Library,
1457 open: Option<&Section>,1493 count: usize,
1458 path: *const c_char,1494 section: *const c_char,
1459 query: *const c_char,1495 query: *const c_char,
1460) -> *mut c_char {1496) -> *mut c_char {
1461 let path = optional(path).unwrap_or_default();1497 // SAFETY: the caller passes `count` libraries.
1462 json(library.search(open.map(|section| (path.as_str(), section)), &string(query)))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 ))
1463}1504}
14641505
1465/// The notebook's tagged paragraphs in page order as JSON, once for each of their tags:1506/// The notebook's tagged paragraphs in page order as JSON, once for each of their tags:
1466/// each with `section`, `page`, `title`, `paragraph`, `name`, `shape` (the tag's symbol, 01507/// 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`1508/// for a highlighting tag), `checked` and `text`. Open sections are read as their edits
1468/// takes them; reads the notebook's sections, so call it off the main thread.1509/// leave 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.
1472#[unsafe(no_mangle)]1510#[unsafe(no_mangle)]
1473pub unsafe extern "C" fn sb_library_tagged(1511pub extern "C" fn sb_library_tagged(library: &Library) -> *mut c_char {
1474 library: &Library,1512 json(library.tagged())
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))))
1480}1513}
14811514
1482/// Unlocks the password-protected section at catalog `path` with `password`, as OneNote's1515/// 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() {...@@ -403,15 +403,52 @@ fn search_finds_pages_by_title_and_text_across_sections() {
403 true,403 true,
404 )404 )
405 .unwrap();405 .unwrap();
406 let found = library.search(None, "collapsed PARENT").unwrap();406 let found = library::search(&[&library], None, "collapsed PARENT").unwrap();
407 assert!(!found.is_empty());407 assert!(!found.is_empty());
408 assert!(found[0].snippet.to_lowercase().contains("collapsed parent"));408 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 );
409 assert!(417 assert!(
410 library418 library::search(&[&library], None, "no such words anywhere")
411 .search(None, "no such words anywhere")
412 .unwrap()419 .unwrap()
413 .is_empty()420 .is_empty()
414 );421 );
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);
415}452}
416453
417#[test]454#[test]
...@@ -826,7 +863,7 @@ fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() {...@@ -826,7 +863,7 @@ fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() {
826 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());863 let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap());
827 let path = file.to_string_lossy().into_owned();864 let path = file.to_string_lossy().into_owned();
828 let section = open(&library, &path);865 let section = open(&library, &path);
829 let tagged = library.tagged(Some((&path, &section))).unwrap();866 let tagged = library.tagged().unwrap();
830 assert!(tagged.len() >= 9, "{}", tagged.len());867 assert!(tagged.len() >= 9, "{}", tagged.len());
831 assert!(868 assert!(
832 tagged869 tagged