diff --git a/apps/ios/Snowbound/Lists.swift b/apps/ios/Snowbound/Lists.swift index b1412bc185f247520c93397bd42aed5a46e3106f..35a0ca01c1b9eb1ab1e7f80c50acf5125c1a8c27 100644 --- a/apps/ios/Snowbound/Lists.swift +++ b/apps/ios/Snowbound/Lists.swift @@ -817,7 +817,7 @@ final class PagesViewController: UITableViewController { private(set) var section: Section? private var items: [Item] = [] private var selected: String? - private let search = SearchViewController.controller() + private lazy var search = SearchViewController.controller { [weak self] in self?.section } /// Opens a page of the section. var onOpen: ((Section, String) -> Void)? @@ -1026,27 +1026,61 @@ final class PagesViewController: UITableViewController { } } -/// A page `sb_library_search` found. +/// A page `sb_search` found. struct Found: Decodable { + let notebook: Int let section: String let page: String let title: String + let inTitle: Bool + let titleHits: [[Int]] let snippet: String + let snippetHits: [[Int]] } -/// Every open notebook's pages holding a query, grouped by notebook, as Notes searches -/// every folder. -final class SearchViewController: UITableViewController, UISearchResultsUpdating { - private var found: [(notebook: Notebook, pages: [Found])] = [] +/// The desktop's search over every open notebook's pages: title matches, then body matches, +/// each with its notebook and section. Searched from a section, it offers OneNote's scopes, +/// starting at This Section. +final class SearchViewController: UITableViewController, UISearchResultsUpdating, UISearchBarDelegate, + UISearchControllerDelegate +{ + private enum Scope: Int, CaseIterable { + case section, notebook, all + + var title: String { + switch self { + case .section: "This Section" + case .notebook: "This Notebook" + case .all: "All Notebooks" + } + } + } + + /// The section searched from, which the scopes narrow to. + private let place: () -> Section? + private weak var search: UISearchController? + private var found: [(found: Found, notebook: Notebook)] = [] private var query = "" private var searching: DispatchWorkItem? - /// A search bar whose results this shows. - static func controller() -> UISearchController { - let results = SearchViewController(style: .insetGrouped) + private init(place: @escaping () -> Section?) { + self.place = place + super.init(style: .insetGrouped) + } + + required init?(coder: NSCoder) { fatalError() } + + /// A search bar whose results this shows, with scopes where `place` gives the section + /// it is searched from. + static func controller(place: (() -> Section?)? = nil) -> UISearchController { + let results = SearchViewController(place: place ?? { nil }) let search = UISearchController(searchResultsController: results) + results.search = search search.searchResultsUpdater = results - search.searchBar.placeholder = "Search Notebooks" + search.delegate = results + search.searchBar.delegate = results + search.searchBar.placeholder = "Search" + if place != nil { search.searchBar.scopeButtonTitles = Scope.allCases.map(\.title) } return search } @@ -1055,10 +1089,25 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating tableView.register(UITableViewCell.self, forCellReuseIdentifier: "found") } + func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { + if let search { updateSearchResults(for: search) } + } + + /// A scope chosen lasts for the search; the next starts at This Section again. + func didDismissSearchController(_ searchController: UISearchController) { + searchController.searchBar.selectedScopeButtonIndex = Scope.section.rawValue + } + func updateSearchResults(for controller: UISearchController) { searching?.cancel() let query = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? "" guard !query.isEmpty else { return show([], query: query) } + let open = place() + let scope = open == nil ? .all : Scope(rawValue: controller.searchBar.selectedScopeButtonIndex) ?? .all + let notebooks = scope == .all ? Notebooks.all : open.map { [$0.notebook] } ?? [] + let searched = notebooks.filter { $0.handle != nil } + let libraries = searched.map { Int(bitPattern: $0.handle) } + let section = scope == .section ? open?.tab.path : nil // Each keystroke waits for the next before the notebooks are read. let work = DispatchWorkItem { [weak self] in if self?.found.isEmpty == true { @@ -1066,30 +1115,21 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating loading.text = "Searching…" self?.contentUnavailableConfiguration = loading } - let notebooks = Notebooks.all.compactMap { notebook -> (Notebook, Int, Int, String?)? in - guard let library = notebook.handle else { return nil } - // Open sections are searched as their edits leave them. - let open = Section.all.first { $0.notebook === notebook } - return (notebook, Int(bitPattern: library), open.map { Int(bitPattern: $0.handle) } ?? 0, open?.tab.path) - } background({ - notebooks.map { notebook, library, open, path in - let found = decode( - [Found].self, - sb_library_search( - OpaquePointer(bitPattern: library), OpaquePointer(bitPattern: open), path, query)) - return (notebook: notebook, pages: found ?? []) - } + let pointers = libraries.map { OpaquePointer(bitPattern: $0) } + return decode([Found].self, sb_search(pointers, pointers.count, section, query)) ?? [] }) { [weak self] found in - guard controller.searchBar.text?.trimmingCharacters(in: .whitespaces) == query else { return } - self?.show(found.filter { !$0.pages.isEmpty }, query: query) + let now = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) + guard now == query, open == nil || controller.searchBar.selectedScopeButtonIndex == scope.rawValue + else { return } + self?.show(found.map { ($0, searched[$0.notebook]) }, query: query) } } searching = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work) } - private func show(_ found: [(notebook: Notebook, pages: [Found])], query: String) { + private func show(_ found: [(found: Found, notebook: Notebook)], query: String) { self.found = found self.query = query tableView.reloadData() @@ -1097,49 +1137,71 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil } - override func numberOfSections(in tableView: UITableView) -> Int { found.count } + /// The results in the title's group or the body's, as the desktop's headings split them. + private func group(_ section: Int) -> ArraySlice<(found: Found, notebook: Notebook)> { + let titled = found.prefix { $0.found.inTitle } + return titled.isEmpty || section == 1 ? found.dropFirst(titled.count) : titled + } + + override func numberOfSections(in tableView: UITableView) -> Int { + (found.first?.found.inTitle == true ? 1 : 0) + (found.last?.found.inTitle == false ? 1 : 0) + } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - found[section].notebook.name + let pages = group(section) + return "\(pages.first?.found.inTitle == true ? "Title" : "Body") contains: \(query) (\(pages.count))" } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - found[section].pages.count + group(section).count + } + + private func result(_ indexPath: IndexPath) -> (found: Found, notebook: Notebook) { + let pages = group(indexPath.section) + return pages[pages.startIndex + indexPath.row] } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let (notebook, pages) = found[indexPath.section] - let page = pages[indexPath.row] + let (page, notebook) = result(indexPath) let cell = tableView.dequeueReusableCell(withIdentifier: "found", for: indexPath) var content = UIListContentConfiguration.subtitleCell() - content.text = page.title.isEmpty ? "Untitled Page" : page.title + let font = UIFont.preferredFont(forTextStyle: .body) + content.attributedText = + page.title.isEmpty + ? NSAttributedString(string: "Untitled Page", attributes: [.font: font]) + : marked(page.title, page.titleHits, font: font, color: .label) let tab = notebook.tabs.first { $0.path == page.section } content.image = UIImage(systemName: "rectangle.portrait.fill") content.imageProperties.tintColor = tab?.uiColor ?? .systemGray - content.secondaryAttributedText = highlighted( - [tab?.name ?? "", page.snippet].filter { !$0.isEmpty }.joined(separator: " · ")) - content.secondaryTextProperties.numberOfLines = 2 + // Where the page is, as the desktop names it: notebook, groups and section. + let groups: [String] = tab?.group.split(separator: "/").map(String.init) ?? [] + let place = ([notebook.name] + groups + [tab?.name ?? ""]).filter { !$0.isEmpty }.joined(separator: " › ") + let detail = NSMutableAttributedString( + string: place + "\n", + attributes: [.font: UIFont.preferredFont(forTextStyle: .footnote), .foregroundColor: UIColor.secondaryLabel]) + detail.append( + marked( + page.snippet, page.snippetHits, font: .preferredFont(forTextStyle: .subheadline), + color: .secondaryLabel)) + content.secondaryAttributedText = detail + content.secondaryTextProperties.numberOfLines = 3 cell.contentConfiguration = content return cell } - /// `text` with the query in bold where it occurs. - private func highlighted(_ text: String) -> NSAttributedString { - let font = UIFont.preferredFont(forTextStyle: .subheadline) - let attributed = NSMutableAttributedString( - string: text, attributes: [.font: font, .foregroundColor: UIColor.secondaryLabel]) - var range = text.startIndex.. NSAttributedString { + let marked = NSMutableAttributedString(string: text, attributes: [.font: font, .foregroundColor: color]) + for hit in hits where hit.count == 2 && hit[1] <= marked.length { + marked.addAttributes( + [.backgroundColor: UIColor(red: 1, green: 0.82, blue: 0, alpha: 0.45), .foregroundColor: UIColor.label], + range: NSRange(location: hit[0], length: hit[1] - hit[0])) } - return attributed + return marked } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let (notebook, pages) = found[indexPath.section] - let page = pages[indexPath.row] + let (page, notebook) = result(indexPath) (view.window?.windowScene?.delegate as? SceneDelegate)? .open(page.section, of: notebook, page: page.page, reveal: .text(query)) } diff --git a/apps/ios/Snowbound/Tags.swift b/apps/ios/Snowbound/Tags.swift index cc2676721b620785a5750ea238973328d7ece294..5f2babb5ee1b5836d8f8aeca58d3ec8fdfa708ba 100644 --- a/apps/ios/Snowbound/Tags.swift +++ b/apps/ios/Snowbound/Tags.swift @@ -133,12 +133,8 @@ final class TagsViewController: UITableViewController { loading.text = "Finding Tags…" contentUnavailableConfiguration = loading guard let library = notebook.handle else { return } - let open = Section.all.first { $0.notebook === notebook } - let (pointer, section, path) = (Int(bitPattern: library), open.map { Int(bitPattern: $0.handle) } ?? 0, open?.tab.path) - background({ - decode([Tagged].self, sb_library_tagged(OpaquePointer(bitPattern: pointer), OpaquePointer(bitPattern: section), path)) - ?? [] - }) { [weak self] tagged in + let pointer = Int(bitPattern: library) + background({ decode([Tagged].self, sb_library_tagged(OpaquePointer(bitPattern: pointer))) ?? [] }) { [weak self] tagged in self?.all = tagged self?.show() } diff --git a/crates/canvas/src/search.rs b/crates/canvas/src/search.rs index b64e1b0a01e01bc644db6ad3b77decad8462595f..fb8fea6dd31c2f4eea7a10202665964d48da79e6 100644 --- a/crates/canvas/src/search.rs +++ b/crates/canvas/src/search.rs @@ -329,6 +329,13 @@ pub struct Found { pub snippet_hits: Vec>, } +impl Found { + /// Where OneNote lists the page: title matches first, then the most recently modified. + pub fn rank(&self) -> (bool, std::cmp::Reverse) { + (!self.in_title, std::cmp::Reverse(self.modified)) + } +} + /// Pages of any number of sections, searchable together. #[derive(Default)] pub struct Index { @@ -379,7 +386,7 @@ impl Index { if query.is_empty() { return Vec::new(); } - let mut found: Vec<(usize, &Entry, bool)> = self + let mut found: Vec = self .entries .iter() .enumerate() @@ -393,10 +400,6 @@ impl Index { }); matches.then_some((order, entry, in_title)) }) - .collect(); - found.sort_by_key(|(_, entry, in_title)| (!in_title, std::cmp::Reverse(entry.modified))); - found - .into_iter() .map(|(order, entry, in_title)| { let (snippet, snippet_hits) = snippet(entry, query); Found { @@ -411,7 +414,9 @@ impl Index { snippet_hits, } }) - .collect() + .collect(); + found.sort_by_key(Found::rank); + found } /// The tagged paragraphs of the pages whose section and page `scope` accepts, in page diff --git a/crates/mobile/include/snowbound.h b/crates/mobile/include/snowbound.h index 15acffebb715f69856fcf8625111206ad97a4aac..d73295f43992e8a5fcbc3b4efc11a4816e64b86e 100644 --- a/crates/mobile/include/snowbound.h +++ b/crates/mobile/include/snowbound.h @@ -32,8 +32,8 @@ char *sb_library_sections(const Library *library); int32_t sb_library_color(const Library *library); char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author, const char *date, const char *time); -char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query); -char *sb_library_tagged(const Library *library, const Section *open, const char *path); +char *sb_search(const Library *const *libraries, size_t count, const char *section, const char *query); +char *sb_library_tagged(const Library *library); bool sb_library_unlock(const Library *library, const char *path, const char *password); void sb_library_lock_all(const Library *library); char *sb_library_sync_status(const Library *library); diff --git a/crates/mobile/src/library.rs b/crates/mobile/src/library.rs index 7033efe767343116959aa443c7bc8be79afcfeed..6c7a0d6dc40246ae5041ceebac633b6dbf76b5d3 100644 --- a/crates/mobile/src/library.rs +++ b/crates/mobile/src/library.rs @@ -993,37 +993,73 @@ const CHANGED: u32 = 2; const REJECTED: u32 = 4; #[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] pub(crate) struct Found { + /// Which of the notebooks searched holds the page. + pub(crate) notebook: usize, /// The section's catalog path. pub(crate) section: String, pub(crate) page: String, pub(crate) title: String, + /// Every word is in the title, as OneNote's "Title contains" lists it. + pub(crate) in_title: bool, + /// Where the words match `title`, as UTF-16 `[start, end]`. + pub(crate) title_hits: Vec<[usize; 2]>, /// The line around the first match. pub(crate) snippet: String, + pub(crate) snippet_hits: Vec<[usize; 2]>, } -impl Library { - /// Pages of the notebook holding every word of `query` in their title or text, as the - /// desktop searches. - pub(crate) fn search(&self, open: Option<(&str, &Section)>, query: &str) -> Result> { - self.indexed(open, |index| { - index - .search(&Query::new(query), |_| true) - .into_iter() - .map(|found| Found { - section: found.section, - page: found.space.to_string(), - title: one_line(&found.title), - snippet: found.snippet, - }) - .collect() - }) +/// Pages of `libraries` holding every word of `query` in their title or text, as the desktop +/// searches and lists them; only of the section at catalog `section` where given. +pub(crate) fn search( + libraries: &[&Library], + section: Option<&str>, + query: &str, +) -> Result> { + let query = Query::new(query); + let mut found = Vec::new(); + for (notebook, library) in libraries.iter().enumerate() { + library.indexed(|index| { + let scope = |key: &str| section.is_none_or(|section| key == section); + found.extend( + index + .search(&query, scope) + .into_iter() + .map(|found| (notebook, found)), + ); + })?; } + found.sort_by_key(|(_, found)| found.rank()); + Ok(found + .into_iter() + .map(|(notebook, found)| Found { + notebook, + page: found.space.to_string(), + title: one_line(&found.title), + in_title: found.in_title, + title_hits: utf16(&found.title, found.title_hits), + snippet_hits: utf16(&found.snippet, found.snippet_hits), + snippet: found.snippet, + section: found.section, + }) + .collect()) +} +/// Byte ranges of `text` as UTF-16 `[start, end]`, as `NSRange` counts. +fn utf16(text: &str, ranges: Vec>) -> Vec<[usize; 2]> { + let units = |end: usize| text[..end].encode_utf16().count(); + ranges + .into_iter() + .map(|range| [units(range.start), units(range.end)]) + .collect() +} + +impl Library { /// The notebook's tagged paragraphs in page order, as OneNote's Tags Summary lists /// them: once for each of their tags. - pub(crate) fn tagged(&self, open: Option<(&str, &Section)>) -> Result> { - self.indexed(open, |index| { + pub(crate) fn tagged(&self) -> Result> { + self.indexed(|index| { index .tagged(|_| true) .into_iter() @@ -1041,13 +1077,10 @@ impl Library { }) } - /// `read` over the notebook's pages: the open section as its edits leave it, others as - /// stored, read again when their file changed. - fn indexed( - &self, - open: Option<(&str, &Section)>, - read: impl FnOnce(&Index) -> T, - ) -> Result { + /// `read` over the notebook's pages: the sections open as their edits leave them, others + /// as stored, read again when their file changed. + fn indexed(&self, read: impl FnOnce(&Index) -> T) -> Result { + let open = self.open_sections(); let tabs: Vec = self .tabs()? .into_iter() @@ -1057,9 +1090,9 @@ impl Library { let mut indexed = self.index.lock().unwrap_or_else(|error| error.into_inner()); let (checked, index) = &mut *indexed; for path in &tabs { - match open.filter(|(open, _)| open == path) { - Some((_, section)) => { - let section = §ion.shared.section; + match open.iter().find(|(open, _)| open == path) { + Some((_, shared)) => { + let section = &shared.section; let mut entries = Vec::new(); for (space, ..) in section.pages()? { 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( .map_or(std::ptr::null_mut(), owned) } -/// Pages of the notebook whose title or text holds `query`, as JSON: each with `section` -/// (a catalog path), `page`, `title` and `snippet`. `open` is the section being edited, at -/// catalog path `path`, or null. Reads the notebook's sections, so call it off the main -/// thread. +/// Pages of the `count` notebooks at `libraries` whose title or text holds `query`, title +/// matches first and then the most recently changed, as JSON: each with `notebook` (its +/// place in `libraries`), `section` (a catalog path), `page`, `title`, `inTitle`, +/// `snippet`, and `titleHits` and `snippetHits`, UTF-16 `[start, end]`. Only the section +/// at catalog path `section` where non-null. Open sections are searched as their edits leave +/// them; reads the notebooks' sections, so call it off the main thread. /// /// # Safety -/// `query` and a non-null `path` are NUL-terminated UTF-8. +/// `libraries` holds `count` libraries; `query` and a non-null `section` are NUL-terminated +/// UTF-8. #[unsafe(no_mangle)] -pub unsafe extern "C" fn sb_library_search( - library: &Library, - open: Option<&Section>, - path: *const c_char, +pub unsafe extern "C" fn sb_search( + libraries: *const &Library, + count: usize, + section: *const c_char, query: *const c_char, ) -> *mut c_char { - let path = optional(path).unwrap_or_default(); - json(library.search(open.map(|section| (path.as_str(), section)), &string(query))) + // SAFETY: the caller passes `count` libraries. + let libraries = unsafe { std::slice::from_raw_parts(libraries, count) }; + json(search( + libraries, + optional(section).as_deref(), + &string(query), + )) } /// The notebook's tagged paragraphs in page order as JSON, once for each of their tags: /// each with `section`, `page`, `title`, `paragraph`, `name`, `shape` (the tag's symbol, 0 -/// for a highlighting tag), `checked` and `text`. `open` and `path` as `sb_library_search` -/// takes them; reads the notebook's sections, so call it off the main thread. -/// -/// # Safety -/// A non-null `path` is NUL-terminated UTF-8. +/// for a highlighting tag), `checked` and `text`. Open sections are read as their edits +/// leave them; reads the notebook's sections, so call it off the main thread. #[unsafe(no_mangle)] -pub unsafe extern "C" fn sb_library_tagged( - library: &Library, - open: Option<&Section>, - path: *const c_char, -) -> *mut c_char { - let path = optional(path).unwrap_or_default(); - json(library.tagged(open.map(|section| (path.as_str(), section)))) +pub extern "C" fn sb_library_tagged(library: &Library) -> *mut c_char { + json(library.tagged()) } /// Unlocks the password-protected section at catalog `path` with `password`, as OneNote's diff --git a/crates/mobile/src/tests.rs b/crates/mobile/src/tests.rs index d6131c87f34d66a19073eb979bdeb7ebaa890591..416c587a748b67a384069e2895be49db420de0ad 100644 --- a/crates/mobile/src/tests.rs +++ b/crates/mobile/src/tests.rs @@ -403,15 +403,52 @@ fn search_finds_pages_by_title_and_text_across_sections() { true, ) .unwrap(); - let found = library.search(None, "collapsed PARENT").unwrap(); + let found = library::search(&[&library], None, "collapsed PARENT").unwrap(); assert!(!found.is_empty()); assert!(found[0].snippet.to_lowercase().contains("collapsed parent")); + let [start, end] = found[0].snippet_hits[0]; + let units: Vec = found[0].snippet.encode_utf16().collect(); + assert_eq!( + String::from_utf16(&units[start..end]) + .unwrap() + .to_lowercase(), + "collapsed" + ); assert!( - library - .search(None, "no such words anywhere") + library::search(&[&library], None, "no such words anywhere") .unwrap() .is_empty() ); + let section = &found[0].section; + assert!( + library::search(&[&library], Some(section), "collapsed parent") + .unwrap() + .iter() + .all(|found| found.section == *section) + ); +} + +#[test] +fn search_lists_title_matches_of_every_notebook_first() { + let directory = tempfile::tempdir().unwrap(); + let personal = Library::open( + &corpus("search/notebook"), + &directory.path().join("a"), + true, + ) + .unwrap(); + let other = Library::open( + &corpus("m6/native-features-01/notebook"), + &directory.path().join("b"), + true, + ) + .unwrap(); + let found = library::search(&[&other, &personal], None, "tom").unwrap(); + assert_eq!(found[0].title, "Tomatoes"); + assert_eq!(found[0].notebook, 1); + assert_eq!(found[0].title_hits, [[0, 3]]); + assert!(found[1..].iter().all(|found| !found.in_title)); + assert_eq!(found.len(), 5); } #[test] @@ -826,7 +863,7 @@ fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() { let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap()); let path = file.to_string_lossy().into_owned(); let section = open(&library, &path); - let tagged = library.tagged(Some((&path, §ion))).unwrap(); + let tagged = library.tagged().unwrap(); assert!(tagged.len() >= 9, "{}", tagged.len()); assert!( tagged