max / audiofiles
16 files changed,
+1572 insertions,
-78 deletions
| @@ -1272,7 +1272,7 @@ | |||
| 1272 | 1272 | "libc", | |
| 1273 | 1273 | "option-ext", | |
| 1274 | 1274 | "redox_users", | |
| 1275 | - | "windows-sys 0.59.0", | |
| 1275 | + | "windows-sys 0.61.2", | |
| 1276 | 1276 | ] | |
| 1277 | 1277 | ||
| 1278 | 1278 | [[package]] | |
| @@ -1617,7 +1617,7 @@ | |||
| 1617 | 1617 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 1618 | 1618 | dependencies = [ | |
| 1619 | 1619 | "libc", | |
| 1620 | - | "windows-sys 0.59.0", | |
| 1620 | + | "windows-sys 0.61.2", | |
| 1621 | 1621 | ] | |
| 1622 | 1622 | ||
| 1623 | 1623 | [[package]] | |
| @@ -3224,7 +3224,7 @@ | |||
| 3224 | 3224 | "once_cell", | |
| 3225 | 3225 | "png", | |
| 3226 | 3226 | "thiserror 2.0.20", | |
| 3227 | - | "windows-sys 0.60.2", | |
| 3227 | + | "windows-sys 0.61.2", | |
| 3228 | 3228 | ] | |
| 3229 | 3229 | ||
| 3230 | 3230 | [[package]] | |
| @@ -3309,7 +3309,7 @@ | |||
| 3309 | 3309 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3310 | 3310 | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" | |
| 3311 | 3311 | dependencies = [ | |
| 3312 | - | "windows-sys 0.59.0", | |
| 3312 | + | "windows-sys 0.61.2", | |
| 3313 | 3313 | ] | |
| 3314 | 3314 | ||
| 3315 | 3315 | [[package]] | |
| @@ -4665,7 +4665,7 @@ | |||
| 4665 | 4665 | "errno", | |
| 4666 | 4666 | "libc", | |
| 4667 | 4667 | "linux-raw-sys 0.12.1", | |
| 4668 | - | "windows-sys 0.59.0", | |
| 4668 | + | "windows-sys 0.61.2", | |
| 4669 | 4669 | ] | |
| 4670 | 4670 | ||
| 4671 | 4671 | [[package]] | |
| @@ -4722,7 +4722,7 @@ | |||
| 4722 | 4722 | "security-framework", | |
| 4723 | 4723 | "security-framework-sys", | |
| 4724 | 4724 | "webpki-root-certs", | |
| 4725 | - | "windows-sys 0.59.0", | |
| 4725 | + | "windows-sys 0.61.2", | |
| 4726 | 4726 | ] | |
| 4727 | 4727 | ||
| 4728 | 4728 | [[package]] | |
| @@ -5118,7 +5118,7 @@ | |||
| 5118 | 5118 | checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" | |
| 5119 | 5119 | dependencies = [ | |
| 5120 | 5120 | "libc", | |
| 5121 | - | "windows-sys 0.60.2", | |
| 5121 | + | "windows-sys 0.61.2", | |
| 5122 | 5122 | ] | |
| 5123 | 5123 | ||
| 5124 | 5124 | [[package]] | |
| @@ -5612,7 +5612,7 @@ | |||
| 5612 | 5612 | ||
| 5613 | 5613 | [[package]] | |
| 5614 | 5614 | name = "synckit-client" | |
| 5615 | - | version = "0.9.0" | |
| 5615 | + | version = "0.9.1" | |
| 5616 | 5616 | dependencies = [ | |
| 5617 | 5617 | "apple-native-keyring-store", | |
| 5618 | 5618 | "argon2", | |
| @@ -5712,10 +5712,10 @@ | |||
| 5712 | 5712 | checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" | |
| 5713 | 5713 | dependencies = [ | |
| 5714 | 5714 | "fastrand", | |
| 5715 | - | "getrandom 0.3.4", | |
| 5715 | + | "getrandom 0.4.3", | |
| 5716 | 5716 | "once_cell", | |
| 5717 | 5717 | "rustix 1.1.4", | |
| 5718 | - | "windows-sys 0.59.0", | |
| 5718 | + | "windows-sys 0.61.2", | |
| 5719 | 5719 | ] | |
| 5720 | 5720 | ||
| 5721 | 5721 | [[package]] | |
| @@ -6122,7 +6122,7 @@ | |||
| 6122 | 6122 | "once_cell", | |
| 6123 | 6123 | "png", | |
| 6124 | 6124 | "thiserror 2.0.20", | |
| 6125 | - | "windows-sys 0.60.2", | |
| 6125 | + | "windows-sys 0.61.2", | |
| 6126 | 6126 | ] | |
| 6127 | 6127 | ||
| 6128 | 6128 | [[package]] | |
| @@ -6154,7 +6154,7 @@ | |||
| 6154 | 6154 | dependencies = [ | |
| 6155 | 6155 | "memoffset", | |
| 6156 | 6156 | "tempfile", | |
| 6157 | - | "windows-sys 0.60.2", | |
| 6157 | + | "windows-sys 0.61.2", | |
| 6158 | 6158 | ] | |
| 6159 | 6159 | ||
| 6160 | 6160 | [[package]] | |
| @@ -6746,7 +6746,7 @@ | |||
| 6746 | 6746 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6747 | 6747 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | |
| 6748 | 6748 | dependencies = [ | |
| 6749 | - | "windows-sys 0.59.0", | |
| 6749 | + | "windows-sys 0.61.2", | |
| 6750 | 6750 | ] | |
| 6751 | 6751 | ||
| 6752 | 6752 | [[package]] | |
| @@ -7554,10 +7554,6 @@ | |||
| 7554 | 7554 | "winnow 1.0.4", | |
| 7555 | 7555 | ] | |
| 7556 | 7556 | ||
| 7557 | - | [[patch.unused]] | |
| 7558 | - | name = "quasi-type" | |
| 7559 | - | version = "0.1.0" | |
| 7560 | - | ||
| 7561 | 7557 | [[patch.unused]] | |
| 7562 | 7558 | name = "kberg" | |
| 7563 | 7559 | version = "0.1.0" | |
| @@ -7597,3 +7593,7 @@ | |||
| 7597 | 7593 | [[patch.unused]] | |
| 7598 | 7594 | name = "quasi-webview" | |
| 7599 | 7595 | version = "0.56.0" | |
| 7596 | + | ||
| 7597 | + | [[patch.unused]] | |
| 7598 | + | name = "quasi-type" | |
| 7599 | + | version = "0.1.0" |
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | use crate::vp_tree::VpTree; | |
| 6 | 6 | use rusqlite::{Connection, params}; | |
| 7 | 7 | use serde::{Deserialize, Serialize}; | |
| 8 | + | use std::collections::{HashMap, HashSet}; | |
| 8 | 9 | use tracing::instrument; | |
| 9 | 10 | ||
| 10 | 11 | /// Audio feature vector for similarity comparison. | |
| @@ -931,12 +932,295 @@ | |||
| 931 | 932 | } | |
| 932 | 933 | } | |
| 933 | 934 | ||
| 935 | + | /// One row of a basket answer: a sample, which anchors it came back for, and how | |
| 936 | + | /// near it is to the whole basket. | |
| 937 | + | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] | |
| 938 | + | pub struct BasketHit { | |
| 939 | + | /// The sample found. | |
| 940 | + | pub hash: String, | |
| 941 | + | /// The anchors whose neighbourhoods it appeared in, in basket order. | |
| 942 | + | /// | |
| 943 | + | /// The accounting rather than a count: a sample near one anchor and a sample | |
| 944 | + | /// near all four are different answers, and a merged ranking that showed | |
| 945 | + | /// only a number would be one the reader has to take on trust. | |
| 946 | + | pub matched: Vec<String>, | |
| 947 | + | /// How far it is from the anchor it is furthest from. Smaller is nearer. | |
| 948 | + | pub score: f64, | |
| 949 | + | } | |
| 950 | + | ||
| 951 | + | /// Merge several anchors' neighbourhoods into one "near all of these" ranking. | |
| 952 | + | /// | |
| 953 | + | /// # The distances have to mean the same thing | |
| 954 | + | /// | |
| 955 | + | /// Every distance here is weighted Euclidean over a min-max-normalized vector, | |
| 956 | + | /// so it is a number only in the ranges it was normalized under. Two anchors | |
| 957 | + | /// answered under different ranges produce numbers that cannot be compared, and | |
| 958 | + | /// merging them would rank on an artefact. **This function cannot check that**, | |
| 959 | + | /// because a `Vec<SimilarResult>` does not carry the ranges it was computed | |
| 960 | + | /// under; the caller has to have read every neighbourhood from one source. The | |
| 961 | + | /// worker does: it takes the stored graph for every anchor or the tree for every | |
| 962 | + | /// anchor, never a mixture. | |
| 963 | + | /// | |
| 964 | + | /// # Minimise the worst distance to any anchor | |
| 965 | + | /// | |
| 966 | + | /// The literal reading of "near all of these": a sample's score is its distance | |
| 967 | + | /// from whichever anchor it is furthest from, and the ranking minimises that. | |
| 968 | + | /// A sample near three anchors and far from the fourth is not near the basket. | |
| 969 | + | /// | |
| 970 | + | /// An anchor's neighbourhood is its nearest `k`, so a sample missing from one is | |
| 971 | + | /// not at a known distance from it, only further than the last row that came | |
| 972 | + | /// back. That last row's distance is what stands in, which makes the score a | |
| 973 | + | /// lower bound on the true worst distance rather than a guess: it can rank a | |
| 974 | + | /// sample better than it deserves, never worse. The effect is what it should be | |
| 975 | + | /// anyway, since a sample present in every neighbourhood outranks one present in | |
| 976 | + | /// half. | |
| 977 | + | /// | |
| 978 | + | /// # The basket is never in its own results | |
| 979 | + | /// | |
| 980 | + | /// Every anchor is excluded, not merely the one being looked up. A per-anchor | |
| 981 | + | /// query drops itself, so a basket of four would otherwise hand back its own | |
| 982 | + | /// members as its top answers. | |
| 983 | + | pub fn merge_neighbourhoods( | |
| 984 | + | neighbourhoods: &[(String, Vec<SimilarResult>)], | |
| 985 | + | limit: usize, | |
| 986 | + | ) -> Vec<BasketHit> { | |
| 987 | + | let anchors: HashSet<&str> = neighbourhoods | |
| 988 | + | .iter() | |
| 989 | + | .map(|(anchor, _)| anchor.as_str()) | |
| 990 | + | .collect(); | |
| 991 | + | ||
| 992 | + | // What stands in for "not in this anchor's neighbourhood": the distance of | |
| 993 | + | // the last row it did return. An empty neighbourhood answers nothing about | |
| 994 | + | // anything, so it contributes no penalty rather than an infinite one, which | |
| 995 | + | // would make every result unrankable the moment one anchor came back cold. | |
| 996 | + | let penalties: Vec<Option<f64>> = neighbourhoods | |
| 997 | + | .iter() | |
| 998 | + | .map(|(_, rows)| { | |
| 999 | + | rows.iter() | |
| 1000 | + | .map(|row| row.distance) | |
| 1001 | + | .fold(None, |worst: Option<f64>, d| { | |
| 1002 | + | Some(worst.map_or(d, |w| w.max(d))) | |
| 1003 | + | }) | |
| 1004 | + | }) | |
| 1005 | + | .collect(); | |
| 1006 | + | ||
| 1007 | + | // Distance per anchor per candidate, in basket order, so the accounting | |
| 1008 | + | // below reads out in the order the reader put the anchors in. | |
| 1009 | + | let mut found: HashMap<&str, Vec<Option<f64>>> = HashMap::new(); | |
| 1010 | + | for (at, (_, rows)) in neighbourhoods.iter().enumerate() { | |
| 1011 | + | for row in rows { | |
| 1012 | + | if anchors.contains(row.hash.as_str()) { | |
| 1013 | + | continue; | |
| 1014 | + | } | |
| 1015 | + | let seen = found | |
| 1016 | + | .entry(row.hash.as_str()) | |
| 1017 | + | .or_insert_with(|| vec![None; neighbourhoods.len()]); | |
| 1018 | + | // A neighbourhood should not list a sample twice; if one does, the | |
| 1019 | + | // nearer reading is the one to keep rather than whichever came last. | |
| 1020 | + | seen[at] = Some(seen[at].map_or(row.distance, |d: f64| d.min(row.distance))); | |
| 1021 | + | } | |
| 1022 | + | } | |
| 1023 | + | ||
| 1024 | + | let mut hits: Vec<BasketHit> = found | |
| 1025 | + | .into_iter() | |
| 1026 | + | .map(|(hash, per_anchor)| { | |
| 1027 | + | let mut matched = Vec::new(); | |
| 1028 | + | let mut score: f64 = 0.0; | |
| 1029 | + | for (at, distance) in per_anchor.iter().enumerate() { | |
| 1030 | + | match distance { | |
| 1031 | + | Some(d) => { | |
| 1032 | + | matched.push(neighbourhoods[at].0.clone()); | |
| 1033 | + | score = score.max(*d); | |
| 1034 | + | } | |
| 1035 | + | None => { | |
| 1036 | + | if let Some(penalty) = penalties[at] { | |
| 1037 | + | score = score.max(penalty); | |
| 1038 | + | } | |
| 1039 | + | } | |
| 1040 | + | } | |
| 1041 | + | } | |
| 1042 | + | BasketHit { | |
| 1043 | + | hash: hash.to_owned(), | |
| 1044 | + | matched, | |
| 1045 | + | score, | |
| 1046 | + | } | |
| 1047 | + | }) | |
| 1048 | + | .collect(); | |
| 1049 | + | ||
| 1050 | + | // Score first, which is the ranking. The two tiebreaks are for a stable | |
| 1051 | + | // answer rather than a better one: a basket whose rows shuffle between | |
| 1052 | + | // identical queries is a list the reader cannot keep their place in. | |
| 1053 | + | hits.sort_by(|a, b| { | |
| 1054 | + | a.score | |
| 1055 | + | .total_cmp(&b.score) | |
| 1056 | + | .then_with(|| b.matched.len().cmp(&a.matched.len())) | |
| 1057 | + | .then_with(|| a.hash.cmp(&b.hash)) | |
| 1058 | + | }); | |
| 1059 | + | hits.truncate(limit); | |
| 1060 | + | hits | |
| 1061 | + | } | |
| 1062 | + | ||
| 934 | 1063 | #[cfg(test)] | |
| 935 | 1064 | mod tests { | |
| 936 | 1065 | use super::*; | |
| 937 | 1066 | use crate::analysis::{self, AnalysisResult}; | |
| 938 | 1067 | use crate::test_helpers::insert_fake_sample; | |
| 939 | 1068 | ||
| 1069 | + | /// `(anchor, neighbours)` from a list of `(hash, distance)` pairs. | |
| 1070 | + | fn hood(anchor: &str, rows: &[(&str, f64)]) -> (String, Vec<SimilarResult>) { | |
| 1071 | + | ( | |
| 1072 | + | anchor.to_owned(), | |
| 1073 | + | rows.iter() | |
| 1074 | + | .map(|(hash, distance)| SimilarResult { | |
| 1075 | + | hash: (*hash).to_owned(), | |
| 1076 | + | distance: *distance, | |
| 1077 | + | }) | |
| 1078 | + | .collect(), | |
| 1079 | + | ) | |
| 1080 | + | } | |
| 1081 | + | ||
| 1082 | + | fn ranked(hits: &[BasketHit]) -> Vec<&str> { | |
| 1083 | + | hits.iter().map(|hit| hit.hash.as_str()).collect() | |
| 1084 | + | } | |
| 1085 | + | ||
| 1086 | + | /// The contract the basket rests on: one anchor merged is the one-anchor | |
| 1087 | + | /// query, same rows in the same order. If this ever fails, a basket of one | |
| 1088 | + | /// has become a second kind of search rather than a special case of this one. | |
| 1089 | + | #[test] | |
| 1090 | + | fn a_basket_of_one_is_the_single_anchor_query() { | |
| 1091 | + | let rows = [("b", 0.1), ("c", 0.4), ("d", 0.2)]; | |
| 1092 | + | let hits = merge_neighbourhoods(&[hood("a", &rows)], 10); | |
| 1093 | + | ||
| 1094 | + | let mut expected: Vec<(&str, f64)> = rows.to_vec(); | |
| 1095 | + | expected.sort_by(|a, b| a.1.total_cmp(&b.1)); | |
| 1096 | + | assert_eq!( | |
| 1097 | + | ranked(&hits), | |
| 1098 | + | expected.iter().map(|(h, _)| *h).collect::<Vec<_>>() | |
| 1099 | + | ); | |
| 1100 | + | for hit in &hits { | |
| 1101 | + | assert_eq!(hit.matched, vec!["a".to_owned()]); | |
| 1102 | + | } | |
| 1103 | + | } | |
| 1104 | + | ||
| 1105 | + | /// "Near all of these" is the worst distance, not the sum and not the best. | |
| 1106 | + | /// `near_both` is further from the first anchor than `near_one` is, and still | |
| 1107 | + | /// wins, because `near_one` is not near the second anchor at all. | |
| 1108 | + | #[test] | |
| 1109 | + | fn the_ranking_minimises_the_worst_distance_to_any_anchor() { | |
| 1110 | + | let hits = merge_neighbourhoods( | |
| 1111 | + | &[ | |
| 1112 | + | hood( | |
| 1113 | + | "a", | |
| 1114 | + | &[("near_one", 0.05), ("near_both", 0.30), ("far", 0.90)], | |
| 1115 | + | ), | |
| 1116 | + | hood("b", &[("near_both", 0.20), ("far", 0.80)]), | |
| 1117 | + | ], | |
| 1118 | + | 10, | |
| 1119 | + | ); | |
| 1120 | + | ||
| 1121 | + | assert_eq!(ranked(&hits), vec!["near_both", "near_one", "far"]); | |
| 1122 | + | let near_both = &hits[0]; | |
| 1123 | + | assert!( | |
| 1124 | + | (near_both.score - 0.30).abs() < f64::EPSILON, | |
| 1125 | + | "{near_both:?}" | |
| 1126 | + | ); | |
| 1127 | + | assert_eq!(near_both.matched, vec!["a".to_owned(), "b".to_owned()]); | |
| 1128 | + | } | |
| 1129 | + | ||
| 1130 | + | /// A sample missing from an anchor's neighbourhood is not at a known | |
| 1131 | + | /// distance from it. The last row that anchor did return stands in, so the | |
| 1132 | + | /// score is a lower bound: `near_one` scores 0.80 -- b's worst -- rather than | |
| 1133 | + | /// its own 0.05. | |
| 1134 | + | #[test] | |
| 1135 | + | fn a_sample_missing_from_a_neighbourhood_is_scored_from_that_anchors_last_row() { | |
| 1136 | + | let hits = merge_neighbourhoods( | |
| 1137 | + | &[ | |
| 1138 | + | hood("a", &[("near_one", 0.05)]), | |
| 1139 | + | hood("b", &[("other", 0.80)]), | |
| 1140 | + | ], | |
| 1141 | + | 10, | |
| 1142 | + | ); | |
| 1143 | + | ||
| 1144 | + | let near_one = hits.iter().find(|hit| hit.hash == "near_one").unwrap(); | |
| 1145 | + | assert!((near_one.score - 0.80).abs() < f64::EPSILON, "{near_one:?}"); | |
| 1146 | + | assert_eq!(near_one.matched, vec!["a".to_owned()]); | |
| 1147 | + | } | |
| 1148 | + | ||
| 1149 | + | /// The accounting is the decided part, and it reads out in basket order | |
| 1150 | + | /// however the anchors happened to answer. | |
| 1151 | + | #[test] | |
| 1152 | + | fn each_hit_names_the_anchors_it_answered_to_in_basket_order() { | |
| 1153 | + | let hits = merge_neighbourhoods( | |
| 1154 | + | &[ | |
| 1155 | + | hood("a", &[("x", 0.1)]), | |
| 1156 | + | hood("b", &[]), | |
| 1157 | + | hood("c", &[("x", 0.2)]), | |
| 1158 | + | ], | |
| 1159 | + | 10, | |
| 1160 | + | ); | |
| 1161 | + | ||
| 1162 | + | let x = hits.iter().find(|hit| hit.hash == "x").unwrap(); | |
| 1163 | + | assert_eq!(x.matched, vec!["a".to_owned(), "c".to_owned()]); | |
| 1164 | + | } | |
| 1165 | + | ||
| 1166 | + | /// Every anchor, not merely the one being looked up. A per-anchor query drops | |
| 1167 | + | /// itself, so without this a basket of two hands back its own members. | |
| 1168 | + | #[test] | |
| 1169 | + | fn no_anchor_comes_back_as_its_own_result() { | |
| 1170 | + | let hits = merge_neighbourhoods( | |
| 1171 | + | &[ | |
| 1172 | + | hood("a", &[("b", 0.01), ("x", 0.5)]), | |
| 1173 | + | hood("b", &[("a", 0.01), ("x", 0.6)]), | |
| 1174 | + | ], | |
| 1175 | + | 10, | |
| 1176 | + | ); | |
| 1177 | + | ||
| 1178 | + | assert_eq!(ranked(&hits), vec!["x"]); | |
| 1179 | + | } | |
| 1180 | + | ||
| 1181 | + | /// An anchor that came back cold says nothing about anything, so it must not | |
| 1182 | + | /// make every other anchor's answers unrankable. | |
| 1183 | + | #[test] | |
| 1184 | + | fn an_anchor_with_no_neighbours_penalises_nothing() { | |
| 1185 | + | let hits = merge_neighbourhoods(&[hood("a", &[("x", 0.2)]), hood("b", &[])], 10); | |
| 1186 | + | ||
| 1187 | + | let x = hits.iter().find(|hit| hit.hash == "x").unwrap(); | |
| 1188 | + | assert!((x.score - 0.2).abs() < f64::EPSILON, "{x:?}"); | |
| 1189 | + | } | |
| 1190 | + | ||
| 1191 | + | /// Two queries over an unchanged library give the same list in the same | |
| 1192 | + | /// order, including where scores tie. A ranking that shuffles is one the | |
| 1193 | + | /// reader cannot keep their place in. | |
| 1194 | + | #[test] | |
| 1195 | + | fn the_ranking_is_stable_where_scores_tie() { | |
| 1196 | + | let tied = [ | |
| 1197 | + | hood("a", &[("q", 0.5), ("p", 0.5), ("r", 0.5)]), | |
| 1198 | + | hood("b", &[("r", 0.5), ("q", 0.5), ("p", 0.5)]), | |
| 1199 | + | ]; | |
| 1200 | + | assert_eq!( | |
| 1201 | + | ranked(&merge_neighbourhoods(&tied, 10)), | |
| 1202 | + | vec!["p", "q", "r"] | |
| 1203 | + | ); | |
| 1204 | + | assert_eq!( | |
| 1205 | + | merge_neighbourhoods(&tied, 10), | |
| 1206 | + | merge_neighbourhoods(&tied, 10) | |
| 1207 | + | ); | |
| 1208 | + | } | |
| 1209 | + | ||
| 1210 | + | #[test] | |
| 1211 | + | fn an_empty_basket_answers_nothing() { | |
| 1212 | + | assert!(merge_neighbourhoods(&[], 10).is_empty()); | |
| 1213 | + | } | |
| 1214 | + | ||
| 1215 | + | #[test] | |
| 1216 | + | fn the_limit_takes_the_nearest_rather_than_the_first_found() { | |
| 1217 | + | let hits = merge_neighbourhoods( | |
| 1218 | + | &[hood("a", &[("far", 0.9), ("near", 0.1), ("mid", 0.5)])], | |
| 1219 | + | 2, | |
| 1220 | + | ); | |
| 1221 | + | assert_eq!(ranked(&hits), vec!["near", "mid"]); | |
| 1222 | + | } | |
| 1223 | + | ||
| 940 | 1224 | /// `feature_distance` is documented as a true weighted-Euclidean metric, the | |
| 941 | 1225 | /// VP-tree's triangle-inequality prune depends on it (a pseudometric could | |
| 942 | 1226 | /// drop a genuine nearest neighbour). Prove the property rather than assert it |
| @@ -187,8 +187,10 @@ | |||
| 187 | 187 | ||
| 188 | 188 | // Search events (similarity / near-duplicate VP-tree build + query run on a | |
| 189 | 189 | // worker thread; the GUI resolves the ranked result hashes to nodes when they | |
| 190 | - | // land). Only the hashes cross the boundary, distance/score is unused by the | |
| 191 | - | // list view, so the result structs needn't be serde/Debug. | |
| 190 | + | // land). For the two single-anchor searches only the hashes cross: the list | |
| 191 | + | // view shows a ranking and says nothing about distance. The basket is the | |
| 192 | + | // exception -- it shows which anchors each row came back for -- so | |
| 193 | + | // `BasketHit` is serde where `SimilarResult` is not. | |
| 192 | 194 | SimilarResults { | |
| 193 | 195 | source: String, | |
| 194 | 196 | hashes: Vec<String>, | |
| @@ -197,6 +199,15 @@ | |||
| 197 | 199 | source: String, | |
| 198 | 200 | hashes: Vec<String>, | |
| 199 | 201 | }, | |
| 202 | + | /// Ranked basket hits, each naming the anchors it answered to. | |
| 203 | + | /// | |
| 204 | + | /// The one search result whose distance does cross the boundary, in | |
| 205 | + | /// `BasketHit::matched`: the list view shows which anchors a row came back | |
| 206 | + | /// for, so unlike the two above it is not only the hashes that are wanted. | |
| 207 | + | BasketResults { | |
| 208 | + | sources: Vec<String>, | |
| 209 | + | hits: Vec<audiofiles_core::similarity::BasketHit>, | |
| 210 | + | }, | |
| 200 | 211 | SearchError { | |
| 201 | 212 | error: String, | |
| 202 | 213 | }, | |
| @@ -682,6 +693,14 @@ | |||
| 682 | 693 | /// Start a near-duplicate search for `hash` (fingerprint comparison). Results | |
| 683 | 694 | /// arrive via [`BackendEvent::NearDuplicateResults`] (or `SearchError`). | |
| 684 | 695 | fn start_find_near_duplicates(&self, hash: &str, limit: usize) -> BackendResult<()>; | |
| 696 | + | ||
| 697 | + | /// Start a basket search: the samples nearest to every one of `hashes`. | |
| 698 | + | /// Results arrive via [`BackendEvent::BasketResults`] (or `SearchError`). | |
| 699 | + | /// | |
| 700 | + | /// One call rather than one per anchor, because every anchor has to be | |
| 701 | + | /// answered under one set of normalization ranges for the distances to be | |
| 702 | + | /// comparable. The worker's `neighbourhoods` is where that is enforced. | |
| 703 | + | fn start_find_similar_to_all(&self, hashes: &[String], limit: usize) -> BackendResult<()>; | |
| 685 | 704 | } | |
| 686 | 705 | ||
| 687 | 706 | /// Content-addressed store: import, sample paths, trash, and loose-file maintenance. |
| @@ -28,6 +28,16 @@ | |||
| 28 | 28 | pub enum SearchCommand { | |
| 29 | 29 | /// Find samples similar to `hash` (builds the similarity index if needed). | |
| 30 | 30 | FindSimilar { hash: String, limit: usize }, | |
| 31 | + | /// Find the samples nearest to *all* of `hashes` at once: the basket query. | |
| 32 | + | /// | |
| 33 | + | /// One command rather than N `FindSimilar` calls, and not for the round | |
| 34 | + | /// trips. Distances are only comparable within the ranges they were | |
| 35 | + | /// normalized under, so every anchor has to be answered from one source -- | |
| 36 | + | /// the stored graph for all of them, or one freshly built tree for all of | |
| 37 | + | /// them. Split across N commands the worker could take the graph for some | |
| 38 | + | /// and the tree for others, and the merged ranking would be an artefact of | |
| 39 | + | /// which anchor happened to be queued for update. | |
| 40 | + | FindSimilarToAll { hashes: Vec<String>, limit: usize }, | |
| 31 | 41 | /// Find near-duplicates of `hash` by fingerprint (builds the index if needed). | |
| 32 | 42 | FindNearDuplicates { hash: String, limit: usize }, | |
| 33 | 43 | /// Drop both cached indexes so the next query rebuilds from fresh data, and | |
| @@ -46,6 +56,14 @@ | |||
| 46 | 56 | SimilarResults { source: String, hashes: Vec<String> }, | |
| 47 | 57 | /// Ranked near-duplicate hashes for `source`. | |
| 48 | 58 | NearDuplicateResults { source: String, hashes: Vec<String> }, | |
| 59 | + | /// Ranked basket hits for `sources`, each carrying the anchors it answered | |
| 60 | + | /// to. `sources` echoes the basket the query was run for, so a stale answer | |
| 61 | + | /// can be told apart from the current one the way the single-anchor path | |
| 62 | + | /// tells them apart by `source`. | |
| 63 | + | BasketResults { | |
| 64 | + | sources: Vec<String>, | |
| 65 | + | hits: Vec<similarity::BasketHit>, | |
| 66 | + | }, | |
| 49 | 67 | /// A search failed (DB open / load / build error). | |
| 50 | 68 | Error { error: String }, | |
| 51 | 69 | } | |
| @@ -96,6 +114,95 @@ | |||
| 96 | 114 | ) | |
| 97 | 115 | } | |
| 98 | 116 | ||
| 117 | + | /// Every anchor's neighbourhood, all of them read from one source. | |
| 118 | + | /// | |
| 119 | + | /// **This is where the basket's one correctness trap is closed.** A distance is | |
| 120 | + | /// weighted Euclidean over a min-max-normalized vector, so it means something | |
| 121 | + | /// only in the ranges it was normalized under: the stored graph's ranges are | |
| 122 | + | /// whatever the last build used (`neighbour_graph_meta.ranges`), and a freshly | |
| 123 | + | /// built tree's are computed from the library as it is now. Merging a number | |
| 124 | + | /// from one with a number from the other ranks on an artefact. | |
| 125 | + | /// | |
| 126 | + | /// So the choice is made once for the whole basket rather than per anchor. The | |
| 127 | + | /// graph answers every anchor or none of them: it declines for a stale graph and | |
| 128 | + | /// for any sample queued for update, and one anchor declining sends the whole | |
| 129 | + | /// query to the tree. That is why the basket is one command and not N. | |
| 130 | + | /// | |
| 131 | + | /// The tree is built once and every anchor queried through it, so a basket costs | |
| 132 | + | /// one build rather than one per anchor. | |
| 133 | + | /// | |
| 134 | + | /// An anchor whose features cannot be loaded contributes an empty neighbourhood | |
| 135 | + | /// instead of failing the query -- one unanalysed sample in a basket of four | |
| 136 | + | /// should narrow the answer, not refuse it. When *every* anchor fails that way | |
| 137 | + | /// the error is returned, which is what keeps the one-anchor case reporting a | |
| 138 | + | /// missing sample exactly as it did before. | |
| 139 | + | fn neighbourhoods( | |
| 140 | + | state: &mut SearchState, | |
| 141 | + | hashes: &[String], | |
| 142 | + | limit: usize, | |
| 143 | + | ) -> Result<Vec<(String, Vec<similarity::SimilarResult>)>, audiofiles_core::error::CoreError> { | |
| 144 | + | if hashes.is_empty() { | |
| 145 | + | return Ok(Vec::new()); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // The stored graph first: for a library that has not changed this is a table | |
| 149 | + | // read per anchor and no VP-tree is built at all, which is the point of the | |
| 150 | + | // graph. A miss on any anchor abandons the whole attempt. | |
| 151 | + | let mut stored = Vec::with_capacity(hashes.len()); | |
| 152 | + | for hash in hashes { | |
| 153 | + | match similarity::NeighbourGraph::neighbours(&state.db, hash, limit) { | |
| 154 | + | Ok(Some(rows)) => stored.push((hash.clone(), rows)), | |
| 155 | + | Ok(None) => break, | |
| 156 | + | Err(e) => { | |
| 157 | + | tracing::warn!("neighbour graph read failed, using the tree: {e}"); | |
| 158 | + | break; | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | if stored.len() == hashes.len() { | |
| 163 | + | return Ok(stored); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // Evict the other index: at most one VP-tree resident at a time. | |
| 167 | + | state.fingerprint_index = None; | |
| 168 | + | if state.similarity_index.is_none() { | |
| 169 | + | let data = similarity::SimilarityIndex::load_data(&state.db)?; | |
| 170 | + | state.similarity_index = Some(similarity::SimilarityIndex::build_from_data(data)); | |
| 171 | + | } | |
| 172 | + | let built = state.similarity_index.as_ref().expect("set above"); | |
| 173 | + | ||
| 174 | + | let mut hoods = Vec::with_capacity(hashes.len()); | |
| 175 | + | let mut last_error = None; | |
| 176 | + | for hash in hashes { | |
| 177 | + | match similarity::load_features(&state.db, hash) { | |
| 178 | + | Ok(features) => hoods.push((hash.clone(), built.find_similar(hash, &features, limit))), | |
| 179 | + | Err(e) => { | |
| 180 | + | last_error = Some(e); | |
| 181 | + | hoods.push((hash.clone(), Vec::new())); | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | match last_error { | |
| 186 | + | Some(e) if hoods.iter().all(|(_, rows)| rows.is_empty()) => Err(e), | |
| 187 | + | _ => Ok(hoods), | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | /// Bring the stored graph up to date on the tree that is now resident. | |
| 192 | + | /// | |
| 193 | + | /// Called after the answer is emitted, never before: the reader waits on the | |
| 194 | + | /// query, not on the bookkeeping. Best-effort and a no-op when no tree was | |
| 195 | + | /// built, since the graph answering is exactly the case where none was. The GUI | |
| 196 | + | /// connection may hold the write lock, and a refresh that loses that race just | |
| 197 | + | /// happens on the next query. | |
| 198 | + | fn catch_the_graph_up(state: &SearchState) { | |
| 199 | + | if let Some(index) = state.similarity_index.as_ref() | |
| 200 | + | && let Err(e) = similarity::NeighbourGraph::refresh(&state.db, index) | |
| 201 | + | { | |
| 202 | + | tracing::warn!("neighbour graph refresh failed: {e}"); | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 99 | 206 | fn search_step(state: &mut SearchState, cmd: SearchCommand, ctx: &WorkerCtx<SearchEvent>) { | |
| 100 | 207 | match cmd { | |
| 101 | 208 | SearchCommand::Invalidate { changed } => { | |
| @@ -117,57 +224,34 @@ | |||
| 117 | 224 | } | |
| 118 | 225 | } | |
| 119 | 226 | SearchCommand::FindSimilar { hash, limit } => { | |
| 120 | - | // A malformed feature row that panics is caught by the runtime and | |
| 121 | - | // reported as Error; the worker survives for the next query. | |
| 122 | - | // The stored graph answers first: for a library that has not changed | |
| 123 | - | // this is a table read and no VP-tree is built at all, which is the | |
| 124 | - | // point of the graph. Everything below runs only on a miss. | |
| 125 | - | match similarity::NeighbourGraph::neighbours(&state.db, &hash, limit) { | |
| 126 | - | Ok(Some(rows)) => { | |
| 127 | - | ctx.emit(SearchEvent::SimilarResults { | |
| 128 | - | source: hash, | |
| 129 | - | hashes: rows.into_iter().map(|r| r.hash).collect(), | |
| 130 | - | }); | |
| 131 | - | return; | |
| 132 | - | } | |
| 133 | - | Ok(None) => {} | |
| 134 | - | Err(e) => tracing::warn!("neighbour graph read failed, using the tree: {e}"), | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | // Evict the other index: at most one VP-tree resident at a time. | |
| 138 | - | state.fingerprint_index = None; | |
| 139 | - | let result: Result<Vec<String>, audiofiles_core::error::CoreError> = (|| { | |
| 140 | - | if state.similarity_index.is_none() { | |
| 141 | - | let data = similarity::SimilarityIndex::load_data(&state.db)?; | |
| 142 | - | state.similarity_index = | |
| 143 | - | Some(similarity::SimilarityIndex::build_from_data(data)); | |
| 144 | - | } | |
| 145 | - | let built = state.similarity_index.as_ref().expect("set above"); | |
| 146 | - | let features = similarity::load_features(&state.db, &hash)?; | |
| 147 | - | Ok(built | |
| 148 | - | .find_similar(&hash, &features, limit) | |
| 149 | - | .into_iter() | |
| 150 | - | .map(|r| r.hash) | |
| 151 | - | .collect()) | |
| 152 | - | })(); | |
| 153 | - | ctx.emit(match result { | |
| 154 | - | Ok(hashes) => SearchEvent::SimilarResults { | |
| 227 | + | // One anchor is a basket of one, down to the same merge: see | |
| 228 | + | // `neighbourhoods`. The event shape differs because this query is a | |
| 229 | + | // step on the trail and the basket is not, but the answer does not. | |
| 230 | + | match neighbourhoods(state, std::slice::from_ref(&hash), limit) { | |
| 231 | + | Ok(hoods) => ctx.emit(SearchEvent::SimilarResults { | |
| 155 | 232 | source: hash, | |
| 156 | - | hashes, | |
| 157 | - | }, | |
| 158 | - | Err(e) => SearchEvent::Error { | |
| 233 | + | hashes: similarity::merge_neighbourhoods(&hoods, limit) | |
| 234 | + | .into_iter() | |
| 235 | + | .map(|hit| hit.hash) | |
| 236 | + | .collect(), | |
| 237 | + | }), | |
| 238 | + | Err(e) => ctx.emit(SearchEvent::Error { | |
| 159 | 239 | error: e.to_string(), | |
| 160 | - | }, | |
| 161 | - | }); | |
| 162 | - | ||
| 163 | - | // Answer first, then catch the graph up on the tree that is now | |
| 164 | - | // resident. Best-effort: the GUI connection may hold the write lock, | |
| 165 | - | // and a refresh that loses that race just happens on the next query. | |
| 166 | - | if let Some(index) = state.similarity_index.as_ref() | |
| 167 | - | && let Err(e) = similarity::NeighbourGraph::refresh(&state.db, index) | |
| 168 | - | { | |
| 169 | - | tracing::warn!("neighbour graph refresh failed: {e}"); | |
| 240 | + | }), | |
| 170 | 241 | } | |
| 242 | + | catch_the_graph_up(state); | |
| 243 | + | } | |
| 244 | + | SearchCommand::FindSimilarToAll { hashes, limit } => { | |
| 245 | + | match neighbourhoods(state, &hashes, limit) { | |
| 246 | + | Ok(hoods) => ctx.emit(SearchEvent::BasketResults { | |
| 247 | + | hits: similarity::merge_neighbourhoods(&hoods, limit), | |
| 248 | + | sources: hashes, | |
| 249 | + | }), | |
| 250 | + | Err(e) => ctx.emit(SearchEvent::Error { | |
| 251 | + | error: e.to_string(), | |
| 252 | + | }), | |
| 253 | + | } | |
| 254 | + | catch_the_graph_up(state); | |
| 171 | 255 | } | |
| 172 | 256 | SearchCommand::FindNearDuplicates { hash, limit } => { | |
| 173 | 257 | // Evict the other index: at most one VP-tree resident at a time. | |
| @@ -238,7 +322,9 @@ | |||
| 238 | 322 | assert!(hashes.is_empty()); | |
| 239 | 323 | } | |
| 240 | 324 | SearchEvent::Error { .. } => {} // acceptable: no features for the hash | |
| 241 | - | SearchEvent::NearDuplicateResults { .. } => panic!("wrong event kind"), | |
| 325 | + | SearchEvent::NearDuplicateResults { .. } | SearchEvent::BasketResults { .. } => { | |
| 326 | + | panic!("wrong event kind") | |
| 327 | + | } | |
| 242 | 328 | } | |
| 243 | 329 | ||
| 244 | 330 | // Invalidate + a second query still respond (worker survived). |
| @@ -117,6 +117,10 @@ | |||
| 117 | 117 | const PEAK: &str = "Peak dB"; | |
| 118 | 118 | const TAGS: &str = "Tags"; | |
| 119 | 119 | const PLAY: &str = "Play"; | |
| 120 | + | /// The anchors a row answered to. Not in the sort routes' vocabulary and not | |
| 121 | + | /// sortable: a basket answer is already ranked, exactly as a one-anchor answer | |
| 122 | + | /// is, and the toolbar says so. | |
| 123 | + | const NEAR: &str = "Near"; | |
| 120 | 124 | ||
| 121 | 125 | /// What the Add to Collection act asks for, and what its handler reads back. | |
| 122 | 126 | const COLLECTION: &str = "collection"; | |
| @@ -136,6 +140,7 @@ | |||
| 136 | 140 | .post("/files/{id}/path/copy", copy_path) | |
| 137 | 141 | .post("/files/{id}/reveal", reveal) | |
| 138 | 142 | .post("/files/{id}/similar", find_similar) | |
| 143 | + | .post("/files/{id}/basket", basket) | |
| 139 | 144 | .post("/files/{id}/duplicates", find_duplicates) | |
| 140 | 145 | .post("/files/{id}/edit", edit) | |
| 141 | 146 | .post("/files/{id}/instrument", instrument) | |
| @@ -204,6 +209,17 @@ | |||
| 204 | 209 | Ok(screen(state).into()) | |
| 205 | 210 | } | |
| 206 | 211 | ||
| 212 | + | /// `POST /files/{id}/basket` | |
| 213 | + | /// | |
| 214 | + | /// One address for in and out. The row shows which it is, so the reader is | |
| 215 | + | /// answering "is this part of what I am looking for" rather than choosing | |
| 216 | + | /// between two verbs that differ by a word. | |
| 217 | + | fn basket(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 218 | + | let id = id_of(&request)?; | |
| 219 | + | state.files.toggle_basket(id); | |
| 220 | + | Ok(screen(state).into()) | |
| 221 | + | } | |
| 222 | + | ||
| 207 | 223 | /// `POST /files/{id}/duplicates` | |
| 208 | 224 | fn find_duplicates(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 209 | 225 | let id = id_of(&request)?; | |
| @@ -366,7 +382,16 @@ | |||
| 366 | 382 | columns: columns(state, shown), | |
| 367 | 383 | rows: samples | |
| 368 | 384 | .iter() | |
| 369 | - | .map(|sample| row(sample, shown, current, collection, &collections)) | |
| 385 | + | .map(|sample| { | |
| 386 | + | row( | |
| 387 | + | sample, | |
| 388 | + | shown, | |
| 389 | + | current, | |
| 390 | + | collection, | |
| 391 | + | &collections, | |
| 392 | + | state.bar.basket_showing(), | |
| 393 | + | ) | |
| 394 | + | }) | |
| 370 | 395 | .collect(), | |
| 371 | 396 | // Everything the app has loaded and filtered is here. Windowing | |
| 372 | 397 | // rows it already holds is a renderer's job, which is the note in | |
| @@ -407,6 +432,17 @@ | |||
| 407 | 432 | }; | |
| 408 | 433 | ||
| 409 | 434 | let mut columns = vec![data(NAME, Priority::Essential)]; | |
| 435 | + | // Only while a basket answer is on screen, because it is the only time the | |
| 436 | + | // question it answers ("which of them is this near?") can be asked. Second | |
| 437 | + | // rather than last: it is why the row is in the list at all, and a reason | |
| 438 | + | // shown after the tempo and the key reads as a detail about the row instead. | |
| 439 | + | if state.bar.basket_showing() { | |
| 440 | + | columns.push( | |
| 441 | + | Column::new(NEAR) | |
| 442 | + | .width(Width::Fixed) | |
| 443 | + | .priority(Priority::Secondary), | |
| 444 | + | ); | |
| 445 | + | } | |
| 410 | 446 | if shown.duration { | |
| 411 | 447 | columns.push(data(DUR, Priority::Secondary)); | |
| 412 | 448 | } | |
| @@ -435,8 +471,24 @@ | |||
| 435 | 471 | current: Option<i64>, | |
| 436 | 472 | collection: bool, | |
| 437 | 473 | collections: &[Collection], | |
| 474 | + | basket_showing: bool, | |
| 438 | 475 | ) -> Cells { | |
| 439 | 476 | let mut values = vec![Cell::new(&sample.name)]; | |
| 477 | + | if basket_showing { | |
| 478 | + | // A badge per anchor, in basket order, the way the tags cell holds a run | |
| 479 | + | // of tags: each anchor keeps its own edges rather than becoming a comma | |
| 480 | + | // in a sentence. Three badges beside one badge is the whole point -- | |
| 481 | + | // "near all of these" and "near one of these" are different answers and | |
| 482 | + | // must not look alike. | |
| 483 | + | let mut cell = match sample.matched.first() { | |
| 484 | + | Some(first) => Cell::tag(Tag::badge(first.clone())), | |
| 485 | + | None => Cell::new(""), | |
| 486 | + | }; | |
| 487 | + | for anchor in sample.matched.iter().skip(1) { | |
| 488 | + | cell = cell.token(Tag::badge(anchor.clone())); | |
| 489 | + | } | |
| 490 | + | values.push(cell); | |
| 491 | + | } | |
| 440 | 492 | if shown.duration { | |
| 441 | 493 | values.push(Cell::new(seconds(sample.duration))); | |
| 442 | 494 | } | |
| @@ -565,6 +617,20 @@ | |||
| 565 | 617 | // one screen does not teach a different chord for the same act. | |
| 566 | 618 | acts.push(Act::new("Find Similar", at("similar")).key("shift+f")); | |
| 567 | 619 | acts.push(Act::new("Find Duplicates", at("duplicates")).key("shift+d")); | |
| 620 | + | // The basket, which is the two above asked of several samples at once. One | |
| 621 | + | // entry whose words say which way it goes, because the reader is answering | |
| 622 | + | // one question about this row rather than picking between two verbs. | |
| 623 | + | acts.push( | |
| 624 | + | Act::new( | |
| 625 | + | if sample.basket { | |
| 626 | + | "Take out of the basket" | |
| 627 | + | } else { | |
| 628 | + | "Add to the basket" | |
| 629 | + | }, | |
| 630 | + | at("basket"), | |
| 631 | + | ) | |
| 632 | + | .key("b"), | |
| 633 | + | ); | |
| 568 | 634 | ||
| 569 | 635 | // A collection to put it in, if there is one. Offered for a cloud-only | |
| 570 | 636 | // sample too: membership is a fact about the sample rather than about the |
| @@ -557,6 +557,19 @@ | |||
| 557 | 557 | /// acts that need the file on disk. A sample nobody has fetched can still be | |
| 558 | 558 | /// listed, named and tagged, so this is not the same fact as absence. | |
| 559 | 559 | pub cloud_only: bool, | |
| 560 | + | /// Whether this sample is in the basket -- one of the samples being looked | |
| 561 | + | /// *for*, rather than one of the answers. | |
| 562 | + | /// | |
| 563 | + | /// The row's own control reads it, because add and remove are one question | |
| 564 | + | /// asked of one row: "is this part of what I am looking for". | |
| 565 | + | pub basket: bool, | |
| 566 | + | /// Which basket anchors this row came back for, by name, in basket order. | |
| 567 | + | /// | |
| 568 | + | /// Empty unless a basket answer is on screen. The accounting the merged | |
| 569 | + | /// ranking rests on: a sample near one anchor and a sample near all four are | |
| 570 | + | /// different answers, and a ranking that showed only its own order would be | |
| 571 | + | /// one the reader has to take on trust. | |
| 572 | + | pub matched: Vec<String>, | |
| 560 | 573 | } | |
| 561 | 574 | ||
| 562 | 575 | /// Which columns the file list is showing. | |
| @@ -644,6 +657,9 @@ | |||
| 644 | 657 | /// being told. Adding does not, and the collection is what the act asked the | |
| 645 | 658 | /// user for. | |
| 646 | 659 | fn add_to_collection(&self, id: i64, collection: i64); | |
| 660 | + | ||
| 661 | + | /// Put this sample in the basket, or take it out if it is already in. | |
| 662 | + | fn toggle_basket(&self, id: i64); | |
| 647 | 663 | } | |
| 648 | 664 | ||
| 649 | 665 | /// What a described screen asked the app to do to itself. | |
| @@ -746,6 +762,14 @@ | |||
| 746 | 762 | Leave, | |
| 747 | 763 | /// Go back to this step of the similarity trail. | |
| 748 | 764 | WalkBack(usize), | |
| 765 | + | /// Put a sample in the basket, or take it out of it. | |
| 766 | + | ToggleBasket(i64), | |
| 767 | + | /// Take the anchor at this position out of the basket. | |
| 768 | + | RemoveFromBasket(usize), | |
| 769 | + | /// Show what is near every sample in the basket. | |
| 770 | + | ShowBasket, | |
| 771 | + | /// Empty the basket. | |
| 772 | + | ClearBasket, | |
| 749 | 773 | /// Switch to this vault. | |
| 750 | 774 | OpenVault(i64), | |
| 751 | 775 | /// Delete this vault and everything in it. | |
| @@ -988,6 +1012,27 @@ | |||
| 988 | 1012 | audiofiles_core::vfs::NodeType::Directory | |
| 989 | 1013 | ), | |
| 990 | 1014 | cloud_only: node.cloud_only, | |
| 1015 | + | basket: node | |
| 1016 | + | .node | |
| 1017 | + | .sample_hash | |
| 1018 | + | .as_deref() | |
| 1019 | + | .is_some_and(|hash| self.state.search.basket_position(hash).is_some()), | |
| 1020 | + | // Anchor hashes are what the state holds, because a hash is what | |
| 1021 | + | // a query is about. A name is what a row can show, so the | |
| 1022 | + | // translation happens here, at the edge, rather than being | |
| 1023 | + | // carried through the worker. | |
| 1024 | + | matched: node | |
| 1025 | + | .node | |
| 1026 | + | .sample_hash | |
| 1027 | + | .as_deref() | |
| 1028 | + | .and_then(|hash| self.state.search.basket_matches.get(hash)) | |
| 1029 | + | .map(|anchors| { | |
| 1030 | + | anchors | |
| 1031 | + | .iter() | |
| 1032 | + | .map(|anchor| self.state.search.anchor_name(anchor)) | |
| 1033 | + | .collect() | |
| 1034 | + | }) | |
| 1035 | + | .unwrap_or_default(), | |
| 991 | 1036 | }) | |
| 992 | 1037 | .collect() | |
| 993 | 1038 | } | |
| @@ -1067,6 +1112,10 @@ | |||
| 1067 | 1112 | .push(Intent::RemoveFromCollection(id)); | |
| 1068 | 1113 | } | |
| 1069 | 1114 | ||
| 1115 | + | fn toggle_basket(&self, id: i64) { | |
| 1116 | + | self.intents.borrow_mut().push(Intent::ToggleBasket(id)); | |
| 1117 | + | } | |
| 1118 | + | ||
| 1070 | 1119 | fn add_to_collection(&self, id: i64, collection: i64) { | |
| 1071 | 1120 | self.intents | |
| 1072 | 1121 | .borrow_mut() | |
| @@ -2507,6 +2556,17 @@ | |||
| 2507 | 2556 | /// the end of it. | |
| 2508 | 2557 | trail: Vec<Walked>, | |
| 2509 | 2558 | }, | |
| 2559 | + | /// Samples near every one of several at once: the basket's answer. | |
| 2560 | + | /// | |
| 2561 | + | /// A count rather than the anchors themselves, and that is not an omission. | |
| 2562 | + | /// The basket is drawn beside this by `toolbar::holding` because it exists | |
| 2563 | + | /// in every mode, including none, and it is where an anchor can be taken | |
| 2564 | + | /// out. Naming them twice on one bar would give the reader two lists to | |
| 2565 | + | /// reconcile and one of them no controls. | |
| 2566 | + | Basket { | |
| 2567 | + | /// How many samples the answer is near. | |
| 2568 | + | anchors: usize, | |
| 2569 | + | }, | |
| 2510 | 2570 | } | |
| 2511 | 2571 | ||
| 2512 | 2572 | /// One step of a similarity walk. | |
| @@ -2675,6 +2735,26 @@ | |||
| 2675 | 2735 | ||
| 2676 | 2736 | /// Go back to this step of the similarity trail, this far along it. | |
| 2677 | 2737 | fn walk_back(&self, at: usize); | |
| 2738 | + | ||
| 2739 | + | /// What is in the basket, by name, in the order it was put there. | |
| 2740 | + | /// | |
| 2741 | + | /// On the toolbar rather than on [`Files`] because the basket is a fact | |
| 2742 | + | /// about the whole screen: the rows are what it *answered*, and one of the | |
| 2743 | + | /// states it has is holding samples with no answer on screen at all. | |
| 2744 | + | fn basket(&self) -> Vec<String>; | |
| 2745 | + | ||
| 2746 | + | /// Whether the rows are the basket's answer rather than a folder, a search | |
| 2747 | + | /// or one sample's neighbours. | |
| 2748 | + | fn basket_showing(&self) -> bool; | |
| 2749 | + | ||
| 2750 | + | /// Take the anchor at this position out of the basket. | |
| 2751 | + | fn drop_from_basket(&self, at: usize); | |
| 2752 | + | ||
| 2753 | + | /// Show what is near every sample in the basket. | |
| 2754 | + | fn show_basket(&self); | |
| 2755 | + | ||
| 2756 | + | /// Empty the basket, leaving the walk alone. | |
| 2757 | + | fn empty_basket(&self); | |
| 2678 | 2758 | } | |
| 2679 | 2759 | ||
| 2680 | 2760 | /// The app's toolbar, as the narrow thing a described screen borrows. | |
| @@ -2687,6 +2767,14 @@ | |||
| 2687 | 2767 | ||
| 2688 | 2768 | impl Bar for FromBar<'_> { | |
| 2689 | 2769 | fn place(&self) -> Where { | |
| 2770 | + | // Before the trail, because both are usually true at once: a basket is | |
| 2771 | + | // filled while walking, so the trail is non-empty underneath it. What | |
| 2772 | + | // the list *is* is the basket's answer. | |
| 2773 | + | if self.state.search.basket_showing { | |
| 2774 | + | return Where::Basket { | |
| 2775 | + | anchors: self.state.search.basket.len(), | |
| 2776 | + | }; | |
| 2777 | + | } | |
| 2690 | 2778 | if self.state.search.in_similarity() { | |
| 2691 | 2779 | return Where::Similar { | |
| 2692 | 2780 | trail: self | |
| @@ -2800,6 +2888,31 @@ | |||
| 2800 | 2888 | fn walk_back(&self, at: usize) { | |
| 2801 | 2889 | self.push(Intent::WalkBack(at)); | |
| 2802 | 2890 | } | |
| 2891 | + | ||
| 2892 | + | fn basket(&self) -> Vec<String> { | |
| 2893 | + | self.state | |
| 2894 | + | .search | |
| 2895 | + | .basket | |
| 2896 | + | .iter() | |
| 2897 | + | .map(|anchor| anchor.name.clone().unwrap_or_else(|| "sample".to_owned())) | |
| 2898 | + | .collect() | |
| 2899 | + | } | |
| 2900 | + | ||
| 2901 | + | fn basket_showing(&self) -> bool { | |
| 2902 | + | self.state.search.basket_showing | |
| 2903 | + | } | |
| 2904 | + | ||
| 2905 | + | fn drop_from_basket(&self, at: usize) { | |
| 2906 | + | self.push(Intent::RemoveFromBasket(at)); | |
| 2907 | + | } | |
| 2908 | + | ||
| 2909 | + | fn show_basket(&self) { | |
| 2910 | + | self.push(Intent::ShowBasket); | |
| 2911 | + | } | |
| 2912 | + | ||
| 2913 | + | fn empty_basket(&self) { | |
| 2914 | + | self.push(Intent::ClearBasket); | |
| 2915 | + | } | |
| 2803 | 2916 | } | |
| 2804 | 2917 | ||
| 2805 | 2918 | impl FromBar<'_> { |
| @@ -836,6 +836,23 @@ | |||
| 836 | 836 | // sample and returning to it re-runs that sample's query, which is | |
| 837 | 837 | // the same call the trail was built out of. | |
| 838 | 838 | Intent::WalkBack(at) => state.walk_back_to(at), | |
| 839 | + | // The basket. Adding is by row id because the row is what the reader | |
| 840 | + | // pressed; every other member is positional because the basket is an | |
| 841 | + | // ordered list the toolbar draws, and a position is what a control | |
| 842 | + | // in it can carry. | |
| 843 | + | Intent::ToggleBasket(id) => { | |
| 844 | + | if let Some(hash) = hash_of(state, id) { | |
| 845 | + | state.toggle_in_basket(&hash); | |
| 846 | + | } | |
| 847 | + | } | |
| 848 | + | Intent::RemoveFromBasket(at) => state.remove_from_basket(at), | |
| 849 | + | Intent::ShowBasket => state.find_similar_to_basket(), | |
| 850 | + | // Not `clear_similarity_search`: emptying the basket leaves the walk | |
| 851 | + | // alone, and the reader is still where they were on it. | |
| 852 | + | Intent::ClearBasket => { | |
| 853 | + | state.clear_basket(); | |
| 854 | + | state.refresh_contents(); | |
| 855 | + | } | |
| 839 | 856 | // The sidebar. Two of these hand an already-agreed decision to the | |
| 840 | 857 | // app's own executor: the described control asked with | |
| 841 | 858 | // `Act::confirm`, the runtime answered `Step::Ask`, the user said | |
| @@ -1477,6 +1494,20 @@ | |||
| 1477 | 1494 | } | |
| 1478 | 1495 | } | |
| 1479 | 1496 | ||
| 1497 | + | /// The hash of the row with this id, where it is a sample. | |
| 1498 | + | /// | |
| 1499 | + | /// By id rather than through the selection, unlike [`selected_hash`]: the basket | |
| 1500 | + | /// control is on the row, so the row the reader pressed is the one it acts on | |
| 1501 | + | /// whether or not it is the one in focus. | |
| 1502 | + | fn hash_of(state: &BrowserState, id: i64) -> Option<String> { | |
| 1503 | + | state | |
| 1504 | + | .nav | |
| 1505 | + | .contents | |
| 1506 | + | .iter() | |
| 1507 | + | .find(|node| node.node.id.as_i64() == id) | |
| 1508 | + | .and_then(|node| node.node.sample_hash.as_ref().map(ToString::to_string)) | |
| 1509 | + | } | |
| 1510 | + | ||
| 1480 | 1511 | /// The hash of whatever is selected, where it is a sample. | |
| 1481 | 1512 | fn selected_hash(state: &BrowserState) -> Option<String> { | |
| 1482 | 1513 | state |
| @@ -145,6 +145,9 @@ | |||
| 145 | 145 | fn remove_from_collection(&self, id: i64) { | |
| 146 | 146 | self.asked.borrow_mut().push(format!("uncollect:{id}")); | |
| 147 | 147 | } | |
| 148 | + | fn toggle_basket(&self, id: i64) { | |
| 149 | + | self.asked.borrow_mut().push(format!("basket:{id}")); | |
| 150 | + | } | |
| 148 | 151 | fn add_to_collection(&self, id: i64, collection: i64) { | |
| 149 | 152 | self.asked | |
| 150 | 153 | .borrow_mut() | |
| @@ -366,6 +369,8 @@ | |||
| 366 | 369 | tags: vec!["drums".to_owned(), "loop".to_owned()], | |
| 367 | 370 | directory: false, | |
| 368 | 371 | cloud_only: false, | |
| 372 | + | basket: false, | |
| 373 | + | matched: Vec::new(), | |
| 369 | 374 | } | |
| 370 | 375 | } | |
| 371 | 376 | ||
| @@ -392,6 +397,18 @@ | |||
| 392 | 397 | ||
| 393 | 398 | /// A router call against this file list. | |
| 394 | 399 | fn listing(files: &FakeFiles, request: Request) -> Result<Response, quasi_router::RouteError> { | |
| 400 | + | listing_under(files, &Still, request) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | /// The same call with a toolbar of your choosing, which is what the basket's | |
| 404 | + | /// half of the file list needs: whether a basket answer is showing is a fact | |
| 405 | + | /// about the screen, and the file list reads it off the bar rather than | |
| 406 | + | /// inferring it from the rows. | |
| 407 | + | fn listing_under( | |
| 408 | + | files: &FakeFiles, | |
| 409 | + | bar: &dyn Bar, | |
| 410 | + | request: Request, | |
| 411 | + | ) -> Result<Response, quasi_router::RouteError> { | |
| 395 | 412 | let store = Store::default(); | |
| 396 | 413 | let sync = Offline; | |
| 397 | 414 | let themes = themes(); | |
| @@ -400,7 +417,7 @@ | |||
| 400 | 417 | bulk: &Unchosen, | |
| 401 | 418 | shell: &Quiet, | |
| 402 | 419 | library: &Empty, | |
| 403 | - | bar: &Still, | |
| 420 | + | bar, | |
| 404 | 421 | config: &store, | |
| 405 | 422 | sync: &sync, | |
| 406 | 423 | files, | |
| @@ -1827,6 +1844,7 @@ | |||
| 1827 | 1844 | crate::ui::file_list_menus::reveal_label(), | |
| 1828 | 1845 | "Find Similar", | |
| 1829 | 1846 | "Find Duplicates", | |
| 1847 | + | "Add to the basket", | |
| 1830 | 1848 | "Edit...", | |
| 1831 | 1849 | "Play as Instrument", | |
| 1832 | 1850 | "Export...", | |
| @@ -1884,6 +1902,90 @@ | |||
| 1884 | 1902 | ); | |
| 1885 | 1903 | } | |
| 1886 | 1904 | ||
| 1905 | + | /// One address for in and out, and the row says which it is. The reader is | |
| 1906 | + | /// answering "is this part of what I am looking for" rather than choosing | |
| 1907 | + | /// between two verbs that differ by a word. | |
| 1908 | + | #[test] | |
| 1909 | + | fn one_row_control_says_which_way_it_goes() { | |
| 1910 | + | let out = FakeFiles::with(vec![sample(7, "kick.wav")]); | |
| 1911 | + | let response = listing(&out, Request::get("/files")).expect("answered"); | |
| 1912 | + | let (_, rows) = table_of(screen_of(&response)); | |
| 1913 | + | assert!( | |
| 1914 | + | rows[0] | |
| 1915 | + | .menu | |
| 1916 | + | .iter() | |
| 1917 | + | .any(|act| act.label == "Add to the basket") | |
| 1918 | + | ); | |
| 1919 | + | ||
| 1920 | + | let mut held = sample(7, "kick.wav"); | |
| 1921 | + | held.basket = true; | |
| 1922 | + | let inside = FakeFiles::with(vec![held]); | |
| 1923 | + | let response = listing(&inside, Request::get("/files")).expect("answered"); | |
| 1924 | + | let (_, rows) = table_of(screen_of(&response)); | |
| 1925 | + | assert!( | |
| 1926 | + | rows[0] | |
| 1927 | + | .menu | |
| 1928 | + | .iter() | |
| 1929 | + | .any(|act| act.label == "Take out of the basket") | |
| 1930 | + | ); | |
| 1931 | + | ||
| 1932 | + | listing(&inside, Request::post("/files/7/basket")).expect("answered"); | |
| 1933 | + | assert_eq!(inside.asked(), ["basket:7"]); | |
| 1934 | + | } | |
| 1935 | + | ||
| 1936 | + | /// The accounting, which is the decided part of the basket and not a | |
| 1937 | + | /// nice-to-have. A sample near one anchor and a sample near all three are | |
| 1938 | + | /// different answers and must not look alike. | |
| 1939 | + | #[test] | |
| 1940 | + | fn a_basket_answer_shows_which_anchors_each_row_came_back_for() { | |
| 1941 | + | let mut both = sample(7, "kick.wav"); | |
| 1942 | + | both.matched = vec!["808.wav".to_owned(), "clap.wav".to_owned()]; | |
| 1943 | + | let mut one = sample(8, "snare.wav"); | |
| 1944 | + | one.matched = vec!["808.wav".to_owned()]; | |
| 1945 | + | let files = FakeFiles::with(vec![both, one]); | |
| 1946 | + | let bar = with_basket(&["808.wav", "clap.wav"], true); | |
| 1947 | + | ||
| 1948 | + | let response = listing_under(&files, &bar, Request::get("/files")).expect("answered"); | |
| 1949 | + | let (columns, rows) = table_of(screen_of(&response)); | |
| 1950 | + | ||
| 1951 | + | // Second, not last: it is why the row is in the list at all, and a reason | |
| 1952 | + | // shown after the tempo and the key reads as a detail about the row instead. | |
| 1953 | + | assert_eq!(columns[1].name, "Near"); | |
| 1954 | + | assert!( | |
| 1955 | + | columns[1].sorted.is_none() && columns[1].reorder.is_none(), | |
| 1956 | + | "a basket answer is already ranked, so the column does not offer a sort" | |
| 1957 | + | ); | |
| 1958 | + | ||
| 1959 | + | let tokens = |row: &quasi_router::Cells| -> Vec<String> { | |
| 1960 | + | row.values[1] | |
| 1961 | + | .parts | |
| 1962 | + | .iter() | |
| 1963 | + | .filter_map(|part| match part { | |
| 1964 | + | Node::Token(tag) => Some(tag.label.clone()), | |
| 1965 | + | _ => None, | |
| 1966 | + | }) | |
| 1967 | + | .collect() | |
| 1968 | + | }; | |
| 1969 | + | assert_eq!(tokens(&rows[0]), ["808.wav", "clap.wav"]); | |
| 1970 | + | assert_eq!(tokens(&rows[1]), ["808.wav"]); | |
| 1971 | + | } | |
| 1972 | + | ||
| 1973 | + | /// Only while a basket answer is on screen, because it is the only time the | |
| 1974 | + | /// question it answers can be asked at all. | |
| 1975 | + | #[test] | |
| 1976 | + | fn the_near_column_is_absent_when_the_list_is_not_a_basket_answer() { | |
| 1977 | + | let files = FakeFiles::with(vec![sample(7, "kick.wav")]); | |
| 1978 | + | let response = listing(&files, Request::get("/files")).expect("answered"); | |
| 1979 | + | let (columns, rows) = table_of(screen_of(&response)); | |
| 1980 | + | ||
| 1981 | + | assert!(columns.iter().all(|column| column.name != "Near")); | |
| 1982 | + | assert_eq!( | |
| 1983 | + | rows[0].values.len(), | |
| 1984 | + | columns.len(), | |
| 1985 | + | "cells are positional against the columns" | |
| 1986 | + | ); | |
| 1987 | + | } | |
| 1988 | + | ||
| 1887 | 1989 | #[test] | |
| 1888 | 1990 | fn a_cloud_only_row_offers_the_fetch_and_withholds_what_needs_the_bytes() { | |
| 1889 | 1991 | // The third branch, and the one a description could get quietly wrong: the | |
| @@ -1901,6 +2003,11 @@ | |||
| 1901 | 2003 | "Copy Path", | |
| 1902 | 2004 | "Find Similar", | |
| 1903 | 2005 | "Find Duplicates", | |
| 2006 | + | // Offered for a sample nobody has fetched, on the same grounds | |
| 2007 | + | // collection membership is: what a basket asks about is the | |
| 2008 | + | // analysis, which is a fact about the sample and not about whether | |
| 2009 | + | // its bytes are on this disk. | |
| 2010 | + | "Add to the basket", | |
| 1904 | 2011 | "Delete", | |
| 1905 | 2012 | ] | |
| 1906 | 2013 | ); | |
| @@ -4635,9 +4742,21 @@ | |||
| 4635 | 4742 | false | |
| 4636 | 4743 | } | |
| 4637 | 4744 | ||
| 4745 | + | // A fresh app is looking for nothing in particular. | |
| 4746 | + | fn basket(&self) -> Vec<String> { | |
| 4747 | + | Vec::new() | |
| 4748 | + | } | |
| 4749 | + | ||
| 4750 | + | fn basket_showing(&self) -> bool { | |
| 4751 | + | false | |
| 4752 | + | } | |
| 4753 | + | ||
| 4638 | 4754 | fn search(&self, _query: &str) {} | |
| 4639 | 4755 | fn set_scope(&self, _everywhere: bool) {} | |
| 4640 | 4756 | fn save_collection(&self, _name: &str) {} | |
| 4757 | + | fn drop_from_basket(&self, _at: usize) {} | |
| 4758 | + | fn show_basket(&self) {} | |
| 4759 | + | fn empty_basket(&self) {} | |
| 4641 | 4760 | fn undo(&self) {} | |
| 4642 | 4761 | fn toggle(&self, _panel: Panel) {} | |
| 4643 | 4762 | fn go_root(&self) {} | |
| @@ -4652,6 +4771,7 @@ | |||
| 4652 | 4771 | searching: Searching, | |
| 4653 | 4772 | showing: Vec<Panel>, | |
| 4654 | 4773 | undoable: bool, | |
| 4774 | + | basket: Vec<String>, | |
| 4655 | 4775 | asked: RefCell<Vec<String>>, | |
| 4656 | 4776 | } | |
| 4657 | 4777 | ||
| @@ -4672,6 +4792,7 @@ | |||
| 4672 | 4792 | // one; see `Still`. | |
| 4673 | 4793 | showing: vec![Panel::Sidebar], | |
| 4674 | 4794 | undoable: false, | |
| 4795 | + | basket: Vec::new(), | |
| 4675 | 4796 | asked: RefCell::new(Vec::new()), | |
| 4676 | 4797 | } | |
| 4677 | 4798 | } | |
| @@ -4730,6 +4851,26 @@ | |||
| 4730 | 4851 | self.undoable | |
| 4731 | 4852 | } | |
| 4732 | 4853 | ||
| 4854 | + | fn basket(&self) -> Vec<String> { | |
| 4855 | + | self.basket.clone() | |
| 4856 | + | } | |
| 4857 | + | ||
| 4858 | + | fn basket_showing(&self) -> bool { | |
| 4859 | + | matches!(self.place, Where::Basket { .. }) | |
| 4860 | + | } | |
| 4861 | + | ||
| 4862 | + | fn drop_from_basket(&self, at: usize) { | |
| 4863 | + | self.note(format!("drop {at}")); | |
| 4864 | + | } | |
| 4865 | + | ||
| 4866 | + | fn show_basket(&self) { | |
| 4867 | + | self.note("show basket"); | |
| 4868 | + | } | |
| 4869 | + | ||
| 4870 | + | fn empty_basket(&self) { | |
| 4871 | + | self.note("empty basket"); | |
| 4872 | + | } | |
| 4873 | + | ||
| 4733 | 4874 | fn search(&self, query: &str) { | |
| 4734 | 4875 | self.note(format!("search {query}")); | |
| 4735 | 4876 | } | |
| @@ -4860,6 +5001,122 @@ | |||
| 4860 | 5001 | assert!(barred(&bar, Request::post("/here/7/deep")).is_err()); | |
| 4861 | 5002 | } | |
| 4862 | 5003 | ||
| 5004 | + | /// A `FakeBar` holding samples, optionally already showing their answer. | |
| 5005 | + | fn with_basket(held: &[&str], showing: bool) -> FakeBar { | |
| 5006 | + | let mut bar = FakeBar::at(if showing { | |
| 5007 | + | Where::Basket { | |
| 5008 | + | anchors: held.len(), | |
| 5009 | + | } | |
| 5010 | + | } else { | |
| 5011 | + | Where::Folder { trail: Vec::new() } | |
| 5012 | + | }); | |
| 5013 | + | bar.basket = held.iter().map(|name| (*name).to_owned()).collect(); | |
| 5014 | + | bar | |
| 5015 | + | } | |
| 5016 | + | ||
| 5017 | + | /// The basket exists in every mode, including none. A set of samples the reader | |
| 5018 | + | /// is collecting that vanished when they stepped into a folder would be a set | |
| 5019 | + | /// they could not trust. | |
| 5020 | + | #[test] | |
| 5021 | + | fn the_basket_is_on_the_bar_in_a_folder_as_much_as_in_a_walk() { | |
| 5022 | + | let bar = with_basket(&["kick.wav", "snare.wav"], false); | |
| 5023 | + | let screen = topped(&bar); | |
| 5024 | + | ||
| 5025 | + | assert!(said(&screen).contains("Looking for:")); | |
| 5026 | + | // The anchors are links rather than prose, because taking one out is the | |
| 5027 | + | // only thing you can do to it. | |
| 5028 | + | let named: Vec<String> = links(&screen).into_iter().map(|(text, _)| text).collect(); | |
| 5029 | + | assert!(named.contains(&"kick.wav".to_owned()), "{named:?}"); | |
| 5030 | + | assert!(named.contains(&"snare.wav".to_owned()), "{named:?}"); | |
| 5031 | + | } | |
| 5032 | + | ||
| 5033 | + | /// An empty basket is not a query with no results, it is no query. A control for | |
| 5034 | + | /// a question nobody has asked is noise on a bar that already sheds to fit. | |
| 5035 | + | #[test] | |
| 5036 | + | fn an_empty_basket_says_nothing_at_all() { | |
| 5037 | + | let bar = with_basket(&[], false); | |
| 5038 | + | let screen = topped(&bar); | |
| 5039 | + | ||
| 5040 | + | assert!(!said(&screen).contains("Looking for:")); | |
| 5041 | + | assert!(!acts(&screen).iter().any(|label| label.contains("basket"))); | |
| 5042 | + | assert!( | |
| 5043 | + | !links(&screen) | |
| 5044 | + | .iter() | |
| 5045 | + | .any(|(_, action)| action.starts_with("/basket/")) | |
| 5046 | + | ); | |
| 5047 | + | } | |
| 5048 | + | ||
| 5049 | + | /// Each anchor is a link that takes it out: the trail's shape turned around. A | |
| 5050 | + | /// step is somewhere to go back to; an anchor is something to stop asking about. | |
| 5051 | + | #[test] | |
| 5052 | + | fn each_anchor_is_the_control_that_takes_it_out() { | |
| 5053 | + | let bar = with_basket(&["kick.wav", "snare.wav"], false); | |
| 5054 | + | let screen = topped(&bar); | |
| 5055 | + | ||
| 5056 | + | let out: Vec<(String, String)> = links(&screen) | |
| 5057 | + | .into_iter() | |
| 5058 | + | .filter(|(_, action)| action.starts_with("/basket/")) | |
| 5059 | + | .collect(); | |
| 5060 | + | assert_eq!( | |
| 5061 | + | out, | |
| 5062 | + | [ | |
| 5063 | + | ("kick.wav".to_owned(), "/basket/0/remove".to_owned()), | |
| 5064 | + | ("snare.wav".to_owned(), "/basket/1/remove".to_owned()), | |
| 5065 | + | ] | |
| 5066 | + | ); | |
| 5067 | + | ||
| 5068 | + | barred(&bar, Request::post("/basket/1/remove")).expect("answered"); | |
| 5069 | + | assert_eq!(bar.asked(), ["drop 1"]); | |
| 5070 | + | assert!(barred(&bar, Request::post("/basket/one/remove")).is_err()); | |
| 5071 | + | } | |
| 5072 | + | ||
| 5073 | + | /// The count is in the words, because "near all 4" and "near all 2" are | |
| 5074 | + | /// different promises and the reader is about to judge the answer against one. | |
| 5075 | + | #[test] | |
| 5076 | + | fn asking_for_the_answer_says_how_many_it_will_be_near() { | |
| 5077 | + | assert!( | |
| 5078 | + | acts(&topped(&with_basket(&["kick.wav"], false))) | |
| 5079 | + | .contains(&"Show what is near it".to_owned()), | |
| 5080 | + | "one sample is not \"all 1\"" | |
| 5081 | + | ); | |
| 5082 | + | assert!( | |
| 5083 | + | acts(&topped(&with_basket(&["kick.wav", "snare.wav"], false))) | |
| 5084 | + | .contains(&"Show what is near all 2".to_owned()) | |
| 5085 | + | ); | |
| 5086 | + | ||
| 5087 | + | let bar = with_basket(&["kick.wav"], false); | |
| 5088 | + | barred(&bar, Request::post("/basket/show")).expect("answered"); | |
| 5089 | + | barred(&bar, Request::post("/basket/clear")).expect("answered"); | |
| 5090 | + | assert_eq!(bar.asked(), ["show basket", "empty basket"]); | |
| 5091 | + | } | |
| 5092 | + | ||
| 5093 | + | /// Not offered while its answer is what the list is. A control that re-asks an | |
| 5094 | + | /// answered question reads as one that would ask a different one -- and taking | |
| 5095 | + | /// an anchor out has to stay reachable, which is why the chips do not go with it. | |
| 5096 | + | #[test] | |
| 5097 | + | fn the_ask_goes_away_once_its_answer_is_showing_and_the_anchors_do_not() { | |
| 5098 | + | let screen = topped(&with_basket(&["kick.wav", "snare.wav"], true)); | |
| 5099 | + | ||
| 5100 | + | assert!( | |
| 5101 | + | !acts(&screen) | |
| 5102 | + | .iter() | |
| 5103 | + | .any(|label| label.starts_with("Show what is near")) | |
| 5104 | + | ); | |
| 5105 | + | assert!( | |
| 5106 | + | links(&screen) | |
| 5107 | + | .iter() | |
| 5108 | + | .any(|(_, action)| action == "/basket/0/remove"), | |
| 5109 | + | "removing an anchor is how the answer gets re-asked" | |
| 5110 | + | ); | |
| 5111 | + | assert!( | |
| 5112 | + | acts(&screen) | |
| 5113 | + | .iter() | |
| 5114 | + | .any(|label| label == "Back to browsing"), | |
| 5115 | + | "a mode has a way out" | |
| 5116 | + | ); | |
| 5117 | + | assert!(said(&screen).contains("Near all 2")); | |
| 5118 | + | } | |
| 5119 | + | ||
| 4863 | 5120 | #[test] | |
| 4864 | 5121 | fn a_mode_offers_a_way_out_rather_than_a_shorter_path() { | |
| 4865 | 5122 | for place in [ |
| @@ -117,6 +117,9 @@ | |||
| 117 | 117 | .post("/here/{id}/{depth}", go) | |
| 118 | 118 | .post("/here/leave", leave) | |
| 119 | 119 | .post("/here/similar/{at}", walk_back) | |
| 120 | + | .post("/basket/show", show_basket) | |
| 121 | + | .post("/basket/clear", clear_basket) | |
| 122 | + | .post("/basket/{at}/remove", drop_from_basket) | |
| 120 | 123 | } | |
| 121 | 124 | ||
| 122 | 125 | /// `POST /search` | |
| @@ -252,6 +255,32 @@ | |||
| 252 | 255 | Ok(super::shell::screen(state).into()) | |
| 253 | 256 | } | |
| 254 | 257 | ||
| 258 | + | /// `POST /basket/show` | |
| 259 | + | fn show_basket(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 260 | + | state.bar.show_basket(); | |
| 261 | + | Ok(super::shell::screen(state).into()) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | /// `POST /basket/clear` | |
| 265 | + | fn clear_basket(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 266 | + | state.bar.empty_basket(); | |
| 267 | + | Ok(super::shell::screen(state).into()) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | /// `POST /basket/{at}/remove` | |
| 271 | + | /// | |
| 272 | + | /// A position, for [`walk_back`]'s reason: an anchor is only ever a place in | |
| 273 | + | /// this basket, and the sample it names is what the app already holds there. | |
| 274 | + | fn drop_from_basket(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 275 | + | let at: usize = request | |
| 276 | + | .captures | |
| 277 | + | .require("at")? | |
| 278 | + | .parse() | |
| 279 | + | .map_err(|_| RouteError::not_found("no such anchor in the basket"))?; | |
| 280 | + | state.bar.drop_from_basket(at); | |
| 281 | + | Ok(super::shell::screen(state).into()) | |
| 282 | + | } | |
| 283 | + | ||
| 255 | 284 | /// The toolbar, as a region something else holds. | |
| 256 | 285 | pub fn body(state: &Panels<'_>) -> Slot { | |
| 257 | 286 | // A row, said as one. The members were going into the band's body, which is | |
| @@ -266,6 +295,7 @@ | |||
| 266 | 295 | // everything would leave those ranks saying nothing. | |
| 267 | 296 | let bar = Slot::new(BAR, RegionKind::Band).across(layout::Fallback::Shed); | |
| 268 | 297 | let bar = here(bar, state); | |
| 298 | + | let bar = holding(bar, state); | |
| 269 | 299 | let bar = looking(bar, state); | |
| 270 | 300 | panels(bar, state) | |
| 271 | 301 | } | |
| @@ -366,9 +396,81 @@ | |||
| 366 | 396 | Priority::Optional, | |
| 367 | 397 | ) | |
| 368 | 398 | } | |
| 399 | + | // A mode with no trail inside it. You did not arrive here from one | |
| 400 | + | // sample, so there is nowhere in it to go back to; the way out is the | |
| 401 | + | // only way, and the basket itself is beside this on the same bar. | |
| 402 | + | Where::Basket { anchors } => leaving( | |
| 403 | + | bar, | |
| 404 | + | format!("Near all {anchors}"), | |
| 405 | + | "Back to browsing", | |
| 406 | + | ) | |
| 407 | + | .beside( | |
| 408 | + | Node::text("Results are ranked by how near they are to the whole basket, so column sort is off."), | |
| 409 | + | Priority::Optional, | |
| 410 | + | ), | |
| 369 | 411 | } | |
| 370 | 412 | } | |
| 371 | 413 | ||
| 414 | + | /// What you are looking *for*, which is beside where you are rather than part of | |
| 415 | + | /// it. | |
| 416 | + | /// | |
| 417 | + | /// Drawn whenever the basket holds anything, in a folder as much as in a walk: | |
| 418 | + | /// the basket is filled while walking but it is not the walk, and a set of | |
| 419 | + | /// samples the reader is collecting that vanished when they stepped into a | |
| 420 | + | /// folder would be a set they could not trust. | |
| 421 | + | /// | |
| 422 | + | /// Nothing at all when it is empty. An empty basket is not a query with no | |
| 423 | + | /// results, it is no query, and a control for a question nobody has asked is | |
| 424 | + | /// noise on a bar that already sheds members to fit. | |
| 425 | + | fn holding(bar: Slot, state: &Panels<'_>) -> Slot { | |
| 426 | + | let held = state.bar.basket(); | |
| 427 | + | if held.is_empty() { | |
| 428 | + | return bar; | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | let mut bar = bar.beside( | |
| 432 | + | Node::Text { | |
| 433 | + | text: "Looking for:".to_owned(), | |
| 434 | + | tone: Tone::Info, | |
| 435 | + | }, | |
| 436 | + | Priority::Essential, | |
| 437 | + | ); | |
| 438 | + | // Each anchor is a link that takes it out, which is the trail's shape turned | |
| 439 | + | // around: a step is somewhere to go back to, and an anchor is something to | |
| 440 | + | // stop asking about. Both are the only thing you can do to one. | |
| 441 | + | for (at, name) in held.iter().enumerate() { | |
| 442 | + | bar = bar.beside( | |
| 443 | + | Node::Link { | |
| 444 | + | text: name.clone(), | |
| 445 | + | action: Action::post(format!("/basket/{at}/remove")), | |
| 446 | + | }, | |
| 447 | + | Priority::Optional, | |
| 448 | + | ); | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | // Not offered while it is already showing: the answer on screen is the | |
| 452 | + | // answer to this, and a control that re-asks a question already answered | |
| 453 | + | // reads as one that would ask a different one. | |
| 454 | + | if !state.bar.basket_showing() { | |
| 455 | + | bar = bar.beside( | |
| 456 | + | Node::Act(Act::new( | |
| 457 | + | if held.len() == 1 { | |
| 458 | + | "Show what is near it".to_owned() | |
| 459 | + | } else { | |
| 460 | + | format!("Show what is near all {}", held.len()) | |
| 461 | + | }, | |
| 462 | + | Action::post("/basket/show"), | |
| 463 | + | )), | |
| 464 | + | Priority::Essential, | |
| 465 | + | ); | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | bar.beside( | |
| 469 | + | Node::Act(Act::new("Empty the basket", Action::post("/basket/clear"))), | |
| 470 | + | Priority::Optional, | |
| 471 | + | ) | |
| 472 | + | } | |
| 473 | + | ||
| 372 | 474 | /// A mode you are in, and the way out of it. | |
| 373 | 475 | fn leaving(bar: Slot, says: String, out: &str) -> Slot { | |
| 374 | 476 | bar.beside( |
| @@ -307,6 +307,7 @@ | |||
| 307 | 307 | | BackendEvent::ForgeError { .. } => self.handle_forge_event(event), | |
| 308 | 308 | BackendEvent::SimilarResults { .. } | |
| 309 | 309 | | BackendEvent::NearDuplicateResults { .. } | |
| 310 | + | | BackendEvent::BasketResults { .. } | |
| 310 | 311 | | BackendEvent::SearchError { .. } => self.handle_search_event(event), | |
| 311 | 312 | BackendEvent::ClassifierJobDone(_) | BackendEvent::ClassifierJobFailed(_) => { | |
| 312 | 313 | self.handle_classifier_event(event) | |
| @@ -688,10 +689,14 @@ | |||
| 688 | 689 | let refs: Vec<&str> = hashes.iter().map(String::as_str).collect(); | |
| 689 | 690 | self.apply_similarity_results(&source, &refs, "near-duplicates", true); | |
| 690 | 691 | } | |
| 692 | + | BackendEvent::BasketResults { sources, hits } => { | |
| 693 | + | self.apply_basket_results(&sources, hits); | |
| 694 | + | } | |
| 691 | 695 | BackendEvent::SearchError { error } => { | |
| 692 | 696 | // Clear the in-flight request so the repaint-while-busy guard | |
| 693 | 697 | // stops; a failed search is terminal just like a result. | |
| 694 | 698 | self.search.latest_similarity_request = None; | |
| 699 | + | self.search.basket_request = None; | |
| 695 | 700 | self.status = format!("Search failed: {error}"); | |
| 696 | 701 | } | |
| 697 | 702 | _ => unreachable!("handle_search_event: unrelated event"), |
| @@ -265,7 +265,7 @@ | |||
| 265 | 265 | self.nav.current_dir = None; // a filter view has no parent ".." row | |
| 266 | 266 | self.search.search_filter = filter.clone(); | |
| 267 | 267 | self.search.search_query.clone_from(&filter.text_query); | |
| 268 | - | self.search.similarity_trail.clear(); | |
| 268 | + | self.leave_similarity_mode(); | |
| 269 | 269 | self.nav.selection.clear(); | |
| 270 | 270 | self.refresh_contents(); | |
| 271 | 271 | } | |
| @@ -302,7 +302,7 @@ | |||
| 302 | 302 | self.nav.contents = ContentsRef::new(nodes); | |
| 303 | 303 | self.collections_ui.active_collection = Some(id); | |
| 304 | 304 | self.nav.current_dir = None; // a collection view has no parent ".." row | |
| 305 | - | self.search.similarity_trail.clear(); | |
| 305 | + | self.leave_similarity_mode(); | |
| 306 | 306 | self.nav.selection.clear(); | |
| 307 | 307 | self.status = format!("{count} samples in collection"); | |
| 308 | 308 | } | |
| @@ -384,6 +384,12 @@ | |||
| 384 | 384 | // The in-flight request resolved, clear it so the repaint-while-busy | |
| 385 | 385 | // guard stops and a later search starts from a clean slate. | |
| 386 | 386 | self.search.latest_similarity_request = None; | |
| 387 | + | // A trail step is what the list is now, so the basket's answer is not. | |
| 388 | + | // The basket itself stays: it is what the reader is collecting, and | |
| 389 | + | // walking on is how they collect it. | |
| 390 | + | self.search.basket_showing = false; | |
| 391 | + | self.search.basket_matches.clear(); | |
| 392 | + | self.search.basket_request = None; | |
| 387 | 393 | let name = self.backend.sample_original_name(source_hash).ok(); | |
| 388 | 394 | self.record_similarity_step(source_hash, name, is_near_dup); | |
| 389 | 395 | self.nav.selection.clear(); | |
| @@ -448,12 +454,195 @@ | |||
| 448 | 454 | } | |
| 449 | 455 | ||
| 450 | 456 | /// Clear similarity search mode and return to normal browsing. | |
| 457 | + | /// | |
| 458 | + | /// Empties the basket with the trail. The samples in it are the question the | |
| 459 | + | /// reader was asking, and there is no question left once the list is a | |
| 460 | + | /// folder again; keeping them would leave a query armed behind a screen that | |
| 461 | + | /// no longer mentions it. | |
| 451 | 462 | pub fn clear_similarity_search(&mut self) { | |
| 452 | - | self.search.similarity_trail.clear(); | |
| 463 | + | self.leave_similarity_mode(); | |
| 453 | 464 | self.search.latest_similarity_request = None; | |
| 454 | 465 | self.refresh_contents(); | |
| 455 | 466 | } | |
| 456 | 467 | ||
| 468 | + | /// Drop the trail and the basket together, without refreshing. | |
| 469 | + | /// | |
| 470 | + | /// One place decides what leaving means, for | |
| 471 | + | /// [`record_similarity_step`](Self::record_similarity_step)'s reason: every | |
| 472 | + | /// navigation out of similarity mode used to say `similarity_trail.clear()` | |
| 473 | + | /// and there are six of them, so a basket that only some of them emptied | |
| 474 | + | /// would come back armed in a folder that never mentioned it. | |
| 475 | + | pub fn leave_similarity_mode(&mut self) { | |
| 476 | + | self.search.similarity_trail.clear(); | |
| 477 | + | self.clear_basket(); | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | // --- The basket --- | |
| 481 | + | ||
| 482 | + | /// Put a sample in the basket, or take it out if it is already in. | |
| 483 | + | /// | |
| 484 | + | /// One control rather than two, because the row that offers it is the row | |
| 485 | + | /// that shows whether it is in: the reader is answering "is this part of | |
| 486 | + | /// what I am looking for", and that is one question. | |
| 487 | + | /// | |
| 488 | + | /// The name is resolved here, once, for the toolbar to draw every frame. | |
| 489 | + | pub fn toggle_in_basket(&mut self, hash: &str) { | |
| 490 | + | match self.search.basket_position(hash) { | |
| 491 | + | Some(at) => self.remove_from_basket(at), | |
| 492 | + | None => { | |
| 493 | + | let name = self.backend.sample_original_name(hash).ok(); | |
| 494 | + | self.search.basket.push(crate::state::ui::BasketAnchor { | |
| 495 | + | hash: hash.to_string(), | |
| 496 | + | name, | |
| 497 | + | }); | |
| 498 | + | self.status = format!("{} in the basket", self.search.basket.len()); | |
| 499 | + | self.requery_basket(); | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | /// Take the anchor at `at` out of the basket. | |
| 505 | + | pub fn remove_from_basket(&mut self, at: usize) { | |
| 506 | + | if at >= self.search.basket.len() { | |
| 507 | + | return; | |
| 508 | + | } | |
| 509 | + | self.search.basket.remove(at); | |
| 510 | + | self.status = if self.search.basket.is_empty() { | |
| 511 | + | "The basket is empty".to_string() | |
| 512 | + | } else { | |
| 513 | + | format!("{} in the basket", self.search.basket.len()) | |
| 514 | + | }; | |
| 515 | + | self.requery_basket(); | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | /// Empty the basket, and stop showing its answer if it was showing. | |
| 519 | + | pub fn clear_basket(&mut self) { | |
| 520 | + | self.search.basket.clear(); | |
| 521 | + | self.search.basket_showing = false; | |
| 522 | + | self.search.basket_matches.clear(); | |
| 523 | + | self.search.basket_request = None; | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | /// Re-run the basket query, but only if its answer is what is on screen. | |
| 527 | + | /// | |
| 528 | + | /// Adding to the basket while walking must not yank the list out from under | |
| 529 | + | /// the reader: the basket is filled *from* the results they are reading. | |
| 530 | + | /// Once they have asked to see the basket's answer, though, changing the | |
| 531 | + | /// basket has to change the answer -- an anchor removed and the same rows | |
| 532 | + | /// still showing would be a lie about what the list is. | |
| 533 | + | /// | |
| 534 | + | /// An emptied basket falls back to whatever the trail is on, or to the | |
| 535 | + | /// folder when there is no trail: an empty basket is not a query with no | |
| 536 | + | /// results, it is no query. | |
| 537 | + | fn requery_basket(&mut self) { | |
| 538 | + | // Whatever is in flight was asked about a basket that no longer exists. | |
| 539 | + | // Abandoning it here rather than letting the guard in | |
| 540 | + | // `apply_basket_results` catch it is the difference between a stale | |
| 541 | + | // answer being dropped and a stale answer never having been awaited. | |
| 542 | + | self.search.basket_request = None; | |
| 543 | + | if !self.search.basket_showing { | |
| 544 | + | return; | |
| 545 | + | } | |
| 546 | + | if self.search.basket.is_empty() { | |
| 547 | + | self.search.basket_showing = false; | |
| 548 | + | self.search.basket_matches.clear(); | |
| 549 | + | self.search.basket_request = None; | |
| 550 | + | match self.search.similarity_trail.len() { | |
| 551 | + | 0 => self.refresh_contents(), | |
| 552 | + | len => self.walk_back_to(len - 1), | |
| 553 | + | } | |
| 554 | + | return; | |
| 555 | + | } | |
| 556 | + | self.find_similar_to_basket(); | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | /// Ask for the samples nearest to every anchor in the basket. | |
| 560 | + | /// | |
| 561 | + | /// The limit is `GRAPH_K`, [`find_similar`](Self::find_similar)'s reason | |
| 562 | + | /// exactly: past it the stored graph has no edge to answer with, and here it | |
| 563 | + | /// is per anchor, so asking for more would send every basket to a tree build. | |
| 564 | + | pub fn find_similar_to_basket(&mut self) { | |
| 565 | + | if self.search.basket.is_empty() { | |
| 566 | + | self.status = "The basket is empty".to_string(); | |
| 567 | + | return; | |
| 568 | + | } | |
| 569 | + | let hashes: Vec<String> = self | |
| 570 | + | .search | |
| 571 | + | .basket | |
| 572 | + | .iter() | |
| 573 | + | .map(|held| held.hash.clone()) | |
| 574 | + | .collect(); | |
| 575 | + | match self | |
| 576 | + | .backend | |
| 577 | + | .start_find_similar_to_all(&hashes, audiofiles_core::similarity::GRAPH_K) | |
| 578 | + | { | |
| 579 | + | Ok(()) => { | |
| 580 | + | self.search.basket_request = Some(hashes); | |
| 581 | + | self.status = "Searching for what is near all of them...".to_string(); | |
| 582 | + | } | |
| 583 | + | Err(e) => { | |
| 584 | + | self.status = format!("Similarity search failed: {e}"); | |
| 585 | + | } | |
| 586 | + | } | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | /// Apply a basket answer to the file list. | |
| 590 | + | /// | |
| 591 | + | /// The single-anchor path's shape, with the accounting kept: the rows are | |
| 592 | + | /// resolved to nodes in rank order the same way, and `basket_matches` holds | |
| 593 | + | /// which anchors each row came back for so the list can show it. | |
| 594 | + | /// | |
| 595 | + | /// Nothing is recorded on the trail. A basket answer is not a step: you did | |
| 596 | + | /// not arrive at it from one sample and there is nothing to walk back to | |
| 597 | + | /// inside it -- the way out is the basket itself, which is on screen. | |
| 598 | + | pub fn apply_basket_results( | |
| 599 | + | &mut self, | |
| 600 | + | sources: &[String], | |
| 601 | + | hits: Vec<audiofiles_core::similarity::BasketHit>, | |
| 602 | + | ) { | |
| 603 | + | // Drop a stale answer, exactly as the single-anchor path does: the | |
| 604 | + | // reader may have changed the basket while this was in flight. | |
| 605 | + | // | |
| 606 | + | // Two conditions, and the second is not redundant. A query has to be in | |
| 607 | + | // flight, or a late duplicate would re-show an answer the reader has | |
| 608 | + | // left. And the answer has to be about the basket as it is *now* -- | |
| 609 | + | // `sources` is what the worker was asked, which is not necessarily what | |
| 610 | + | // the reader is holding by the time it replies. | |
| 611 | + | if self.search.basket_request.as_deref() != Some(sources) { | |
| 612 | + | return; | |
| 613 | + | } | |
| 614 | + | if !self | |
| 615 | + | .search | |
| 616 | + | .basket | |
| 617 | + | .iter() | |
| 618 | + | .map(|anchor| anchor.hash.as_str()) | |
| 619 | + | .eq(sources.iter().map(String::as_str)) | |
| 620 | + | { | |
| 621 | + | return; | |
| 622 | + | } | |
| 623 | + | let Some(vfs_id) = self.current_vfs_id() else { | |
| 624 | + | return; | |
| 625 | + | }; | |
| 626 | + | let hashes: Vec<&str> = hits.iter().map(|hit| hit.hash.as_str()).collect(); | |
| 627 | + | let nodes = self | |
| 628 | + | .backend | |
| 629 | + | .find_nodes_by_hashes(vfs_id, &hashes) | |
| 630 | + | .unwrap_or_default(); | |
| 631 | + | let count = nodes.len(); | |
| 632 | + | self.search.basket_matches = hits | |
| 633 | + | .into_iter() | |
| 634 | + | .map(|hit| (hit.hash, hit.matched)) | |
| 635 | + | .collect(); | |
| 636 | + | self.nav.contents = ContentsRef::new(nodes); | |
| 637 | + | // Results replace the folder view, so there is no parent ".." row. | |
| 638 | + | self.nav.current_dir = None; | |
| 639 | + | self.search.basket_request = None; | |
| 640 | + | self.search.basket_showing = true; | |
| 641 | + | self.nav.selection.clear(); | |
| 642 | + | let anchors = self.search.basket.len(); | |
| 643 | + | self.status = format!("Found {count} near all {anchors}"); | |
| 644 | + | } | |
| 645 | + | ||
| 457 | 646 | // --- Column config --- | |
| 458 | 647 | ||
| 459 | 648 | /// Load column config from the user_config table. |