| author | |
| committer | |
| log | 1a9bd5592f8f9bd6781eb91b4b2c383af90a4f9b |
| tree | a5efe67fb45480eef2f4f16a40229e06c2486721 |
| parent | 5e3e8d16b21e71aabde09deeb19ccfa44505cb32 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.56 files changed, 251 insertions(+), 118 deletions(-)
apps/ios/Snowbound/Lists.swift+110-48| ... | ... | @@ -817,7 +817,7 @@ final class PagesViewController: UITableViewController { |
| 817 | 817 | private(set) var section: Section? |
| 818 | 818 | private var items: [Item] = [] |
| 819 | 819 | private var selected: String? |
| 820 | private let search = SearchViewController.controller() | |
| 820 | private lazy var search = SearchViewController.controller { [weak self] in self?.section } | |
| 821 | 821 | /// Opens a page of the section. |
| 822 | 822 | var onOpen: ((Section, String) -> Void)? |
| 823 | 823 | |
| ... | ... | @@ -1026,27 +1026,61 @@ final class PagesViewController: UITableViewController { |
| 1026 | 1026 | } |
| 1027 | 1027 | } |
| 1028 | 1028 | |
| 1029 | /// A page `sb_library_search` found. | |
| 1029 | /// A page `sb_search` found. | |
| 1030 | 1030 | struct Found: Decodable { |
| 1031 | let notebook: Int | |
| 1031 | 1032 | let section: String |
| 1032 | 1033 | let page: String |
| 1033 | 1034 | let title: String |
| 1035 | let inTitle: Bool | |
| 1036 | let titleHits: [[Int]] | |
| 1034 | 1037 | let snippet: String |
| 1038 | let snippetHits: [[Int]] | |
| 1035 | 1039 | } |
| 1036 | 1040 | |
| 1037 | /// Every open notebook's pages holding a query, grouped by notebook, as Notes searches | |
| 1038 | /// every folder. | |
| 1039 | final 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. | |
| 1044 | final 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 | 1063 | private var query = "" |
| 1042 | 1064 | private var searching: DispatchWorkItem? |
| 1043 | 1065 | |
| 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 }) | |
| 1047 | 1077 | let search = UISearchController(searchResultsController: results) |
| 1078 | results.search = search | |
| 1048 | 1079 | 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 | 1084 | return search |
| 1051 | 1085 | } |
| 1052 | 1086 | |
| ... | ... | @@ -1055,10 +1089,25 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating |
| 1055 | 1089 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "found") |
| 1056 | 1090 | } |
| 1057 | 1091 | |
| 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 | 1101 | func updateSearchResults(for controller: UISearchController) { |
| 1059 | 1102 | searching?.cancel() |
| 1060 | 1103 | let query = controller.searchBar.text?.trimmingCharacters(in: .whitespaces) ?? "" |
| 1061 | 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 | 1111 | // Each keystroke waits for the next before the notebooks are read. |
| 1063 | 1112 | let work = DispatchWorkItem { [weak self] in |
| 1064 | 1113 | if self?.found.isEmpty == true { |
| ... | ... | @@ -1066,30 +1115,21 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating |
| 1066 | 1115 | loading.text = "Searching…" |
| 1067 | 1116 | 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 | 1118 | 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)) ?? [] | |
| 1083 | 1121 | }) { [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) | |
| 1086 | 1126 | } |
| 1087 | 1127 | } |
| 1088 | 1128 | searching = work |
| 1089 | 1129 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work) |
| 1090 | 1130 | } |
| 1091 | 1131 | |
| 1092 | private func show(_ found: [(notebook: Notebook, pages: [Found])], query: String) { | |
| 1132 | private func show(_ found: [(found: Found, notebook: Notebook)], query: String) { | |
| 1093 | 1133 | self.found = found |
| 1094 | 1134 | self.query = query |
| 1095 | 1135 | tableView.reloadData() |
| ... | ... | @@ -1097,49 +1137,71 @@ final class SearchViewController: UITableViewController, UISearchResultsUpdating |
| 1097 | 1137 | found.isEmpty && !query.isEmpty ? UIContentUnavailableConfiguration.search() : nil |
| 1098 | 1138 | } |
| 1099 | 1139 | |
| 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 | } | |
| 1101 | 1149 | |
| 1102 | 1150 | 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))" | |
| 1104 | 1153 | } |
| 1105 | 1154 | |
| 1106 | 1155 | 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] | |
| 1108 | 1162 | } |
| 1109 | 1163 | |
| 1110 | 1164 | 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) | |
| 1113 | 1166 | let cell = tableView.dequeueReusableCell(withIdentifier: "found", for: indexPath) |
| 1114 | 1167 | 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) | |
| 1116 | 1173 | let tab = notebook.tabs.first { $0.path == page.section } |
| 1117 | 1174 | content.image = UIImage(systemName: "rectangle.portrait.fill") |
| 1118 | 1175 | 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 | |
| 1122 | 1188 | cell.contentConfiguration = content |
| 1123 | 1189 | return cell |
| 1124 | 1190 | } |
| 1125 | 1191 | |
| 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])) | |
| 1136 | 1199 | } |
| 1137 | return attributed | |
| 1200 | return marked | |
| 1138 | 1201 | } |
| 1139 | 1202 | |
| 1140 | 1203 | 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) | |
| 1143 | 1205 | (view.window?.windowScene?.delegate as? SceneDelegate)? |
| 1144 | 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 | 133 | loading.text = "Finding Tags…" |
| 134 | 134 | contentUnavailableConfiguration = loading |
| 135 | 135 | 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 | |
| 142 | 138 | self?.all = tagged |
| 143 | 139 | self?.show() |
| 144 | 140 | } |
crates/canvas/src/search.rs+11-6| ... | ... | @@ -329,6 +329,13 @@ pub struct Found { |
| 329 | 329 | pub snippet_hits: Vec<Range<usize>>, |
| 330 | 330 | } |
| 331 | 331 | |
| 332 | impl 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 | 339 | /// Pages of any number of sections, searchable together. |
| 333 | 340 | #[derive(Default)] |
| 334 | 341 | pub struct Index { |
| ... | ... | @@ -379,7 +386,7 @@ impl Index { |
| 379 | 386 | if query.is_empty() { |
| 380 | 387 | return Vec::new(); |
| 381 | 388 | } |
| 382 | let mut found: Vec<(usize, &Entry, bool)> = self | |
| 389 | let mut found: Vec<Found> = self | |
| 383 | 390 | .entries |
| 384 | 391 | .iter() |
| 385 | 392 | .enumerate() |
| ... | ... | @@ -393,10 +400,6 @@ impl Index { |
| 393 | 400 | }); |
| 394 | 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 | 403 | .map(|(order, entry, in_title)| { |
| 401 | 404 | let (snippet, snippet_hits) = snippet(entry, query); |
| 402 | 405 | Found { |
| ... | ... | @@ -411,7 +414,9 @@ impl Index { |
| 411 | 414 | snippet_hits, |
| 412 | 415 | } |
| 413 | 416 | }) |
| 414 | .collect() | |
| 417 | .collect(); | |
| 418 | found.sort_by_key(Found::rank); | |
| 419 | found | |
| 415 | 420 | } |
| 416 | 421 | |
| 417 | 422 | /// 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 | 32 | int32_t sb_library_color(const Library *library); |
| 33 | 33 | char *sb_library_new_section(const Library *library, const char *folder, const char *name, const char *author, |
| 34 | 34 | const char *date, const char *time); |
| 35 | char *sb_library_search(const Library *library, const Section *open, const char *path, const char *query); | |
| 36 | char *sb_library_tagged(const Library *library, const Section *open, const char *path); | |
| 35 | char *sb_search(const Library *const *libraries, size_t count, const char *section, const char *query); | |
| 36 | char *sb_library_tagged(const Library *library); | |
| 37 | 37 | bool sb_library_unlock(const Library *library, const char *path, const char *password); |
| 38 | 38 | void sb_library_lock_all(const Library *library); |
| 39 | 39 | char *sb_library_sync_status(const Library *library); |
crates/mobile/src/library.rs+85-52| ... | ... | @@ -993,37 +993,73 @@ const CHANGED: u32 = 2; |
| 993 | 993 | const REJECTED: u32 = 4; |
| 994 | 994 | |
| 995 | 995 | #[derive(serde::Serialize)] |
| 996 | #[serde(rename_all = "camelCase")] | |
| 996 | 997 | pub(crate) struct Found { |
| 998 | /// Which of the notebooks searched holds the page. | |
| 999 | pub(crate) notebook: usize, | |
| 997 | 1000 | /// The section's catalog path. |
| 998 | 1001 | pub(crate) section: String, |
| 999 | 1002 | pub(crate) page: String, |
| 1000 | 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 | 1008 | /// The line around the first match. |
| 1002 | 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. | |
| 1015 | pub(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 | } |
| 1004 | 1048 | |
| 1005 | impl 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. | |
| 1050 | fn 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 | } | |
| 1022 | 1057 | |
| 1058 | impl Library { | |
| 1023 | 1059 | /// The notebook's tagged paragraphs in page order, as OneNote's Tags Summary lists |
| 1024 | 1060 | /// 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| { | |
| 1027 | 1063 | index |
| 1028 | 1064 | .tagged(|_| true) |
| 1029 | 1065 | .into_iter() |
| ... | ... | @@ -1041,13 +1077,10 @@ impl Library { |
| 1041 | 1077 | }) |
| 1042 | 1078 | } |
| 1043 | 1079 | |
| 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(); | |
| 1051 | 1084 | let tabs: Vec<String> = self |
| 1052 | 1085 | .tabs()? |
| 1053 | 1086 | .into_iter() |
| ... | ... | @@ -1057,9 +1090,9 @@ impl Library { |
| 1057 | 1090 | let mut indexed = self.index.lock().unwrap_or_else(|error| error.into_inner()); |
| 1058 | 1091 | let (checked, index) = &mut *indexed; |
| 1059 | 1092 | 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; | |
| 1063 | 1096 | let mut entries = Vec::new(); |
| 1064 | 1097 | for (space, ..) in section.pages()? { |
| 1065 | 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 | 1477 | .map_or(std::ptr::null_mut(), owned) |
| 1445 | 1478 | } |
| 1446 | 1479 | |
| 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. | |
| 1451 | 1486 | /// |
| 1452 | 1487 | /// # 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 | 1490 | #[unsafe(no_mangle)] |
| 1455 | pub unsafe extern "C" fn sb_library_search( | |
| 1456 | library: &Library, | |
| 1457 | open: Option<&Section>, | |
| 1458 | path: *const c_char, | |
| 1491 | pub unsafe extern "C" fn sb_search( | |
| 1492 | libraries: *const &Library, | |
| 1493 | count: usize, | |
| 1494 | section: *const c_char, | |
| 1459 | 1495 | query: *const c_char, |
| 1460 | 1496 | ) -> *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 | )) | |
| 1463 | 1504 | } |
| 1464 | 1505 | |
| 1465 | 1506 | /// The notebook's tagged paragraphs in page order as JSON, once for each of their tags: |
| 1466 | 1507 | /// 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. | |
| 1472 | 1510 | #[unsafe(no_mangle)] |
| 1473 | pub 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)))) | |
| 1511 | pub extern "C" fn sb_library_tagged(library: &Library) -> *mut c_char { | |
| 1512 | json(library.tagged()) | |
| 1480 | 1513 | } |
| 1481 | 1514 | |
| 1482 | 1515 | /// 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 | 403 | true, |
| 404 | 404 | ) |
| 405 | 405 | .unwrap(); |
| 406 | let found = library.search(None, "collapsed PARENT").unwrap(); | |
| 406 | let found = library::search(&[&library], None, "collapsed PARENT").unwrap(); | |
| 407 | 407 | assert!(!found.is_empty()); |
| 408 | 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 | 417 | assert!( |
| 410 | library | |
| 411 | .search(None, "no such words anywhere") | |
| 418 | library::search(&[&library], None, "no such words anywhere") | |
| 412 | 419 | .unwrap() |
| 413 | 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] | |
| 432 | fn 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 | } |
| 416 | 453 | |
| 417 | 454 | #[test] |
| ... | ... | @@ -826,7 +863,7 @@ fn the_tags_summary_lists_tagged_paragraphs_and_opens_on_them() { |
| 826 | 863 | let library = Arc::new(Library::open(&file, &directory.path().join("cache"), true).unwrap()); |
| 827 | 864 | let path = file.to_string_lossy().into_owned(); |
| 828 | 865 | let section = open(&library, &path); |
| 829 | let tagged = library.tagged(Some((&path, &section))).unwrap(); | |
| 866 | let tagged = library.tagged().unwrap(); | |
| 830 | 867 | assert!(tagged.len() >= 9, "{}", tagged.len()); |
| 831 | 868 | assert!( |
| 832 | 869 | tagged |