0
2
grain-serve searches, suggests follows and lists cameras and places
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
crates/grain-serve/src/index/import.rs+12−2
| @@ -434,16 +434,26 @@ fn names(db: &Connection) -> rusqlite::Result<BTreeSet<String>> { | ||
names.collect() | ||
} | ||
| ||
/// The tables of `db` that hold rows of their own: not a virtual table, | ||
/// such as full-text search's, nor the ones SQLite keeps it in. | ||
fn own(db: &Connection) -> rusqlite::Result<BTreeSet<String>> { | ||
let sql = "SELECT name FROM pragma_table_list | ||
WHERE schema = 'main' AND type = 'table' AND name NOT LIKE 'sqlite_%'"; | ||
let mut stmt = db.prepare(sql)?; | ||
let names = stmt.query_map([], |r| r.get(0))?; | ||
names.collect() | ||
} | ||
| ||
/// The index at `into`, made, or migrated if it is one already, so long as | ||
/// it holds nothing; set for one large write. | ||
fn empty_index(into: &Path) -> anyhow::Result<Connection> { | ||
let mut db = Connection::open(into).with_context(|| format!("opening {}", into.display()))?; | ||
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; | ||
if let Some(table) = names(&db)?.first().filter(|_| version == 0) { | ||
if let Some(table) = own(&db)?.first().filter(|_| version == 0) { | ||
bail!("{} is not an index: it has {table}", into.display()); | ||
} | ||
migrate(&mut db, schema::MIGRATIONS)?; | ||
for table in names(&db)? { | ||
for table in own(&db)? { | ||
if db | ||
.prepare(&format!("SELECT 1 FROM \"{table}\""))? | ||
.exists([])? | ||
crates/grain-serve/src/index/plan.rs+6−2
| @@ -54,7 +54,8 @@ pub fn sized(db: &Connection) { | ||
} | ||
| ||
/// The tables `sql`'s plan reads whole, rather than by an index: what no | ||
/// query of a table that grows with the network may do. | ||
/// query of a table that grows with the network may do. A full-text | ||
/// search reads its table through the match, which is its index. | ||
pub fn scans(db: &Connection, sql: &str) -> Vec<String> { | ||
let words: Vec<&str> = sql | ||
.split(|c: char| c.is_whitespace() || "(),".contains(c)) | ||
| @@ -92,7 +93,10 @@ pub fn scans(db: &Connection, sql: &str) -> Vec<String> { | ||
}; | ||
let name = rest.split_whitespace().next().unwrap_or_default(); | ||
let table = aliases.get(name).copied().unwrap_or(name); | ||
tables.contains(table) && !rest.contains(" USING ") | ||
// A virtual table read through a constraint, as full-text | ||
// search reads one by MATCH, is read by its index. | ||
let constrained = rest.contains(" VIRTUAL TABLE INDEX ") && !rest.ends_with(':'); | ||
tables.contains(table) && !rest.contains(" USING ") && !constrained | ||
}) | ||
.collect() | ||
} | ||
crates/grain-serve/src/index/schema.rs+84−1
| @@ -29,6 +29,17 @@ | ||
//! is where each stream was read to, by source (`jetstream:<url>`, as | ||
//! hatk's `_cursor` keys it). | ||
//! | ||
//! # Search | ||
//! | ||
//! `profile_search` and `gallery_search` are SQLite FTS5 tables of the | ||
//! words a profile or gallery is found by: its author's handle, a | ||
//! profile's name and description, a gallery's title, description and | ||
//! place. Porter-stemmed and without diacritics, as hatk's `_fts_*` | ||
//! tables were. Triggers on `profile`, `gallery` and `repo` keep them as | ||
//! those tables are; `search_doc` gives each record the rowid it has in | ||
//! them, since a record's own rowid may change on a `VACUUM`. They are | ||
//! made from the records, so rebuilding these rebuilds them. | ||
//! | ||
//! `record_rev` is the revision each record was last written or deleted | ||
//! at (`gone`), for backfill to leave alone what is newer than the repo it | ||
//! read: a write the stream applied after the repo was exported, or a | ||
| @@ -60,7 +71,7 @@ | ||
| ||
/// Each step of the schema, run once, in order, by `PRAGMA user_version`. | ||
/// A step is only ever appended: one that has run is never changed. | ||
pub const MIGRATIONS: &[&str] = &[V1, V2, V3]; | ||
pub const MIGRATIONS: &[&str] = &[V1, V2, V3, V4]; | ||
| ||
const V1: &str = " | ||
CREATE TABLE gallery ( | ||
| @@ -248,6 +259,78 @@ CREATE INDEX follow_did_created ON follow (did, created_at); | ||
CREATE INDEX favorite_subject_created ON favorite (subject, created_at); | ||
"; | ||
| ||
/// Full-text search of profiles and galleries, kept by triggers, and | ||
/// filled from what the index holds. | ||
const V4: &str = " | ||
CREATE TABLE search_doc (id INTEGER PRIMARY KEY, uri TEXT NOT NULL UNIQUE); | ||
CREATE VIRTUAL TABLE profile_search USING fts5( | ||
handle, display_name, description, tokenize = 'porter unicode61 remove_diacritics 2'); | ||
CREATE VIRTUAL TABLE gallery_search USING fts5( | ||
handle, title, description, place, tokenize = 'porter unicode61 remove_diacritics 2'); | ||
| ||
CREATE TRIGGER profile_search_insert AFTER INSERT ON profile BEGIN | ||
INSERT OR IGNORE INTO search_doc (uri) VALUES (NEW.uri); | ||
INSERT INTO profile_search (rowid, handle, display_name, description) | ||
SELECT d.id, (SELECT handle FROM repo WHERE did = NEW.did), NEW.display_name, NEW.description | ||
FROM search_doc d WHERE d.uri = NEW.uri; | ||
END; | ||
CREATE TRIGGER profile_search_update AFTER UPDATE ON profile BEGIN | ||
DELETE FROM profile_search WHERE rowid = (SELECT id FROM search_doc WHERE uri = OLD.uri); | ||
INSERT INTO profile_search (rowid, handle, display_name, description) | ||
SELECT d.id, (SELECT handle FROM repo WHERE did = NEW.did), NEW.display_name, NEW.description | ||
FROM search_doc d WHERE d.uri = NEW.uri; | ||
END; | ||
CREATE TRIGGER profile_search_delete AFTER DELETE ON profile BEGIN | ||
DELETE FROM profile_search WHERE rowid = (SELECT id FROM search_doc WHERE uri = OLD.uri); | ||
DELETE FROM search_doc WHERE uri = OLD.uri; | ||
END; | ||
| ||
CREATE TRIGGER gallery_search_insert AFTER INSERT ON gallery BEGIN | ||
INSERT OR IGNORE INTO search_doc (uri) VALUES (NEW.uri); | ||
INSERT INTO gallery_search (rowid, handle, title, description, place) | ||
SELECT d.id, (SELECT handle FROM repo WHERE did = NEW.did), NEW.title, NEW.description, | ||
concat_ws(' ', json_extract(NEW.location, '$.name'), json_extract(NEW.address, '$.locality'), | ||
json_extract(NEW.address, '$.region'), json_extract(NEW.address, '$.country')) | ||
FROM search_doc d WHERE d.uri = NEW.uri; | ||
END; | ||
CREATE TRIGGER gallery_search_update AFTER UPDATE ON gallery BEGIN | ||
DELETE FROM gallery_search WHERE rowid = (SELECT id FROM search_doc WHERE uri = OLD.uri); | ||
INSERT INTO gallery_search (rowid, handle, title, description, place) | ||
SELECT d.id, (SELECT handle FROM repo WHERE did = NEW.did), NEW.title, NEW.description, | ||
concat_ws(' ', json_extract(NEW.location, '$.name'), json_extract(NEW.address, '$.locality'), | ||
json_extract(NEW.address, '$.region'), json_extract(NEW.address, '$.country')) | ||
FROM search_doc d WHERE d.uri = NEW.uri; | ||
END; | ||
CREATE TRIGGER gallery_search_delete AFTER DELETE ON gallery BEGIN | ||
DELETE FROM gallery_search WHERE rowid = (SELECT id FROM search_doc WHERE uri = OLD.uri); | ||
DELETE FROM search_doc WHERE uri = OLD.uri; | ||
END; | ||
| ||
CREATE TRIGGER repo_search_insert AFTER INSERT ON repo BEGIN | ||
UPDATE profile_search SET handle = NEW.handle WHERE rowid IN | ||
(SELECT d.id FROM profile p JOIN search_doc d ON d.uri = p.uri WHERE p.did = NEW.did); | ||
UPDATE gallery_search SET handle = NEW.handle WHERE rowid IN | ||
(SELECT d.id FROM gallery g JOIN search_doc d ON d.uri = g.uri WHERE g.did = NEW.did); | ||
END; | ||
CREATE TRIGGER repo_search_handle AFTER UPDATE OF handle ON repo BEGIN | ||
UPDATE profile_search SET handle = NEW.handle WHERE rowid IN | ||
(SELECT d.id FROM profile p JOIN search_doc d ON d.uri = p.uri WHERE p.did = NEW.did); | ||
UPDATE gallery_search SET handle = NEW.handle WHERE rowid IN | ||
(SELECT d.id FROM gallery g JOIN search_doc d ON d.uri = g.uri WHERE g.did = NEW.did); | ||
END; | ||
| ||
INSERT INTO search_doc (uri) SELECT uri FROM profile; | ||
INSERT INTO search_doc (uri) SELECT uri FROM gallery; | ||
INSERT INTO profile_search (rowid, handle, display_name, description) | ||
SELECT d.id, r.handle, p.display_name, p.description | ||
FROM profile p JOIN search_doc d ON d.uri = p.uri LEFT JOIN repo r ON r.did = p.did; | ||
INSERT INTO gallery_search (rowid, handle, title, description, place) | ||
SELECT d.id, r.handle, g.title, g.description, | ||
concat_ws(' ', json_extract(g.location, '$.name'), json_extract(g.address, '$.locality'), | ||
json_extract(g.address, '$.region'), json_extract(g.address, '$.country')) | ||
FROM gallery g JOIN search_doc d ON d.uri = g.uri LEFT JOIN repo r ON r.did = g.did; | ||
"; | ||
| ||
/// The label values that leave what carries them out of feeds: the | ||
/// imperative ones, and those whose default is to hide. | ||
pub const HIDDEN_LABELS: [&str; 9] = [ | ||
crates/grain-serve/src/index/tests.rs+0−1
| @@ -454,7 +454,6 @@ fn the_queries_still_to_port_read_by_index() { | ||
// written, by `read::tests::the_reads_read_by_index`; these are the | ||
// ones of what is still to be ported, as they will read. | ||
let queries = [ | ||
"SELECT f.subject FROM bsky_follow f JOIN profile p ON p.did = f.subject WHERE f.did = ?1 LIMIT 10", | ||
"SELECT f.did FROM follow f WHERE f.subject = ?1 ORDER BY f.did LIMIT 50", | ||
"SELECT count(*) FROM group_submission s WHERE s.group_did = ?1 | ||
AND NOT EXISTS (SELECT 1 FROM group_item i WHERE i.did = s.group_did AND i.gallery = s.gallery) | ||
crates/grain-serve/src/read/actor.rs+94−4
| @@ -1,9 +1,9 @@ | ||
//! `social.grain.actor.*`: an account's profile, and the galleries it | ||
//! favorited. | ||
//! `social.grain.actor.*`: an account's profile, the galleries it | ||
//! favorited, and who it might follow. | ||
| ||
use super::moderation::{shown, unhidden}; | ||
use super::moderation::{shown, unblocked, unhidden, unmuted}; | ||
use super::views::put; | ||
use super::{Params, Reader, page}; | ||
use super::{Params, Reader, list, page}; | ||
use crate::cdn::AVATAR; | ||
use crate::index::records::Json; | ||
use crate::xrpc::Failure; | ||
| @@ -170,6 +170,95 @@ pub fn get_favorites(r: &Reader, p: &Params) -> Result<Value, Failure> { | ||
Ok(answer) | ||
} | ||
| ||
/// SQL that holds where `did` is one `?1` might be shown to follow: shown, | ||
/// not followed by `?1` yet, and neither blocking, blocked by nor muted | ||
/// by them. | ||
fn followable(did: &str) -> String { | ||
format!( | ||
"{} AND {did} NOT IN (SELECT subject FROM follow WHERE did = ?1) AND {} AND {}", | ||
shown(did), | ||
unblocked(did, "?1"), | ||
unmuted(did, "?1") | ||
) | ||
} | ||
| ||
/// The accounts `?1` follows on Bluesky that have a grain profile, `?2` | ||
/// of them. | ||
pub fn bsky_follows_sql() -> String { | ||
format!( | ||
"SELECT DISTINCT f.subject FROM bsky_follow f JOIN profile p ON p.did = f.subject | ||
WHERE f.did = ?1 AND f.subject != ?1 AND {} | ||
ORDER BY f.subject LIMIT ?2", | ||
followable("f.subject") | ||
) | ||
} | ||
| ||
/// The most followed accounts with a grain profile, other than `?2`'s, | ||
/// `?3` of them. | ||
pub fn popular_sql() -> String { | ||
format!( | ||
"SELECT s.subject FROM (SELECT subject, count(*) AS n FROM follow GROUP BY subject) s | ||
WHERE EXISTS (SELECT 1 FROM profile p WHERE p.did = s.subject) | ||
AND s.subject NOT IN (SELECT value FROM json_each(?2)) AND {} | ||
ORDER BY s.n DESC, s.subject LIMIT ?3", | ||
followable("s.subject") | ||
) | ||
} | ||
| ||
/// How many follow each of `?1`. | ||
pub const FOLLOWERS_SQL: &str = "SELECT subject, count(DISTINCT did) FROM follow | ||
WHERE subject IN (SELECT value FROM json_each(?1)) GROUP BY subject"; | ||
| ||
/// `social.grain.actor.getSuggestedFollows`: who `actor` might follow on | ||
/// grain, those they follow on Bluesky first, then the most followed. | ||
pub fn get_suggested_follows(r: &Reader, p: &Params) -> Result<Value, Failure> { | ||
let actor = r.actor(p)?; | ||
let limit = p.limit(10, 20)?; | ||
let mut dids: Vec<String> = r | ||
.all::<(String,)>(&bsky_follows_sql(), params![actor, limit as i64])? | ||
.into_iter() | ||
.map(|(d,)| d) | ||
.collect(); | ||
if dids.len() < limit { | ||
let mut not = dids.clone(); | ||
not.push(actor.clone()); | ||
let more: Vec<(String,)> = r.all( | ||
&popular_sql(), | ||
params![actor, list(¬), (limit - dids.len()) as i64], | ||
)?; | ||
dids.extend(more.into_iter().map(|(d,)| d)); | ||
} | ||
let dids: Vec<&str> = dids.iter().map(String::as_str).collect(); | ||
let actors = r.actors(&dids)?; | ||
let followers: std::collections::HashMap<String, i64> = r | ||
.all::<(String, i64)>(FOLLOWERS_SQL, [list(&dids)])? | ||
.into_iter() | ||
.collect(); | ||
let items: Vec<Value> = dids | ||
.iter() | ||
.map(|did| { | ||
let actor = &actors[*did]; | ||
let mut item = json!({ | ||
"did": did, | ||
"handle": actor.handle, | ||
"followersCount": followers.get(*did).copied().unwrap_or(0), | ||
}); | ||
if let Some(p) = &actor.profile { | ||
put(&mut item, "displayName", p.record.display_name.clone()); | ||
put(&mut item, "description", p.record.description.clone()); | ||
let avatar = p.record.avatar.as_ref(); | ||
put( | ||
&mut item, | ||
"avatar", | ||
avatar.and_then(|a| r.cdn.url(&p.did, &a.0, AVATAR)), | ||
); | ||
} | ||
item | ||
}) | ||
.collect(); | ||
Ok(json!({ "items": items })) | ||
} | ||
| ||
/// The SQL these queries run, for the plan check. | ||
#[cfg(test)] | ||
pub fn queries() -> Vec<String> { | ||
| @@ -177,5 +266,6 @@ pub fn queries() -> Vec<String> { | ||
all.extend(COUNTS.iter().map(|s| s.to_string())); | ||
all.push(GERM.into()); | ||
all.push(favorites_sql()); | ||
all.extend([bsky_follows_sql(), popular_sql(), FOLLOWERS_SQL.into()]); | ||
all | ||
} | ||
crates/grain-serve/src/read/camera.rs+77−2
| @@ -4,9 +4,13 @@ | ||
//! find the same galleries. | ||
| ||
use super::feed::{empty, filtered, listed_param}; | ||
use super::moderation::shown; | ||
use super::{Params, Reader}; | ||
use crate::cdn::THUMBNAIL; | ||
use crate::index::records::Photo; | ||
use crate::xrpc::Failure; | ||
use serde_json::Value; | ||
use rusqlite::params; | ||
use serde_json::{Value, json}; | ||
| ||
/// Every make and model the index holds, as EXIF writes them. | ||
pub const CAMERAS_SQL: &str = "SELECT DISTINCT make || ' ' || model FROM photo_exif | ||
| @@ -36,6 +40,72 @@ pub fn feed(r: &Reader, p: &Params) -> Result<Value, Failure> { | ||
filtered(r, p, FILTER, vec![listed_param(&raws)]) | ||
} | ||
| ||
/// How many cameras `getCameras` lists. | ||
const CAMERAS: usize = 30; | ||
| ||
/// How many thumbnails a camera carries, enough for its tile. | ||
const THUMBS: usize = 4; | ||
| ||
/// How many photos each make and model the index holds, the most first. | ||
pub const COUNTS_SQL: &str = "SELECT make, model, count(*) FROM photo_exif | ||
WHERE make IS NOT NULL AND model IS NOT NULL GROUP BY make, model"; | ||
| ||
/// The latest photos taken with the make `?1` and model `?2`, of | ||
/// accounts shown, `?3` of them. | ||
pub fn latest_sql() -> String { | ||
format!( | ||
"SELECT p.* FROM photo_exif e JOIN photo p ON p.uri = e.photo | ||
WHERE e.make = ?1 AND e.model = ?2 AND {} | ||
ORDER BY p.created_at DESC, p.uri LIMIT ?3", | ||
shown("p.did") | ||
) | ||
} | ||
| ||
/// A camera: its name, its count, and the makes and models it is. | ||
type Camera = (String, i64, Vec<(String, String)>); | ||
| ||
/// `social.grain.gallery.getCameras`: the cameras the most photos were | ||
/// taken with, by the name a card gives them, makes and models that read | ||
/// as one counted as one, each with its latest photos. | ||
pub fn get_cameras(r: &Reader, _: &Params) -> Result<Value, Failure> { | ||
let rows: Vec<(String, String, i64)> = r.all(COUNTS_SQL, [])?; | ||
let mut cameras: Vec<Camera> = Vec::new(); | ||
for (make, model, n) in rows { | ||
let name = clean(&format!("{make} {model}")); | ||
if name.is_empty() { | ||
continue; | ||
} | ||
match cameras.iter_mut().find(|c| c.0 == name) { | ||
Some(c) => { | ||
c.1 += n; | ||
c.2.push((make, model)); | ||
} | ||
None => cameras.push((name, n, vec![(make, model)])), | ||
} | ||
} | ||
cameras.sort_by(|a, b| { | ||
b.1.cmp(&a.1) | ||
.then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())) | ||
.then_with(|| a.0.cmp(&b.0)) | ||
}); | ||
cameras.truncate(CAMERAS); | ||
let mut answer = Vec::new(); | ||
for (camera, count, raws) in cameras { | ||
let mut photos = Vec::new(); | ||
for (make, model) in &raws { | ||
photos.extend(r.rows::<Photo>(&latest_sql(), params![make, model, THUMBS as i64])?); | ||
} | ||
photos.sort_by(|a, b| b.record.created_at.cmp(&a.record.created_at)); | ||
let thumbs: Vec<String> = photos | ||
.iter() | ||
.take(THUMBS) | ||
.filter_map(|p| r.cdn.url(&p.did, &p.record.photo.0, THUMBNAIL)) | ||
.collect(); | ||
answer.push(json!({"camera": camera, "photoCount": count, "thumbs": thumbs})); | ||
} | ||
Ok(json!({ "cameras": answer })) | ||
} | ||
| ||
/// A step of a pattern [`RULES`] match. | ||
#[derive(Clone, Copy)] | ||
enum Step { | ||
| @@ -186,7 +256,12 @@ pub fn clean(raw: &str) -> String { | ||
| ||
#[cfg(test)] | ||
pub fn queries() -> Vec<String> { | ||
vec![CAMERAS_SQL.into(), super::feed::filtered_sql(FILTER)] | ||
vec![ | ||
CAMERAS_SQL.into(), | ||
super::feed::filtered_sql(FILTER), | ||
COUNTS_SQL.into(), | ||
latest_sql(), | ||
] | ||
} | ||
| ||
#[cfg(test)] | ||
crates/grain-serve/src/read/location.rs+272−6
| @@ -1,13 +1,16 @@ | ||
//! The Location feed: the galleries at a place, named by the label a view | ||
//! shows for it, by its H3 cell, or both, as hatk's | ||
//! `server/feeds/location.ts` finds them. | ||
//! Places: the Location feed, the galleries at a place, named by the label | ||
//! a view shows for it, by its H3 cell, or both, as hatk's | ||
//! `server/feeds/location.ts` finds them; and `getLocations`, the places | ||
//! galleries were made at, as hatk's `getLocations.ts` counts them. | ||
| ||
use super::feed::{empty, filtered}; | ||
use super::moderation::{shown, unhidden}; | ||
use super::place::{REGIONS, country_code}; | ||
use super::{Params, Reader}; | ||
use super::{Params, Reader, list}; | ||
use crate::cdn::THUMBNAIL; | ||
use crate::xrpc::Failure; | ||
use rusqlite::types::Value as Sql; | ||
use serde_json::Value; | ||
use serde_json::{Value, json}; | ||
| ||
/// Location: by `name`, the label a view shows for a place, where the | ||
/// call gives one and it finds any; otherwise by `location`, an H3 cell, | ||
| @@ -219,11 +222,274 @@ fn resolution(index: u64) -> u64 { | ||
(index >> 52) & 0xf | ||
} | ||
| ||
/// How many places `getLocations` lists in full. | ||
const PLACES: usize = 30; | ||
| ||
/// How many galleries' covers a place carries, enough for its tile. | ||
const THUMBS: usize = 4; | ||
| ||
/// The cell `cell` is in at a city's resolution: itself where it is a | ||
/// city's or coarser; none where it is no cell. | ||
fn city_of(cell: &str) -> Option<String> { | ||
let index = u64::from_str_radix(cell, 16) | ||
.ok() | ||
.filter(|i| mode(*i) == 1)?; | ||
if resolution(index) <= CITY { | ||
return Some(cell.to_string()); | ||
} | ||
let mut at = (index & !(0xf << 52)) | (CITY << 52); | ||
for digit in CITY + 1..=15 { | ||
at |= 0b111 << ((15 - digit) * 3); | ||
} | ||
Some(format!("{at:x}")) | ||
} | ||
| ||
/// Every gallery with a place, of an account shown and carrying no label | ||
/// that hides, the latest first: where it is, and what its address says. | ||
pub fn places_sql() -> String { | ||
format!( | ||
"SELECT g.uri, g.created_at, json_extract(g.location, '$.name'), g.h3, | ||
json_extract(g.address, '$.locality'), json_extract(g.address, '$.region'), | ||
json_extract(g.address, '$.country') | ||
FROM gallery g INDEXED BY gallery_h3 WHERE g.h3 IS NOT NULL AND {} AND {} | ||
ORDER BY g.created_at DESC, g.uri", | ||
shown("g.did"), | ||
unhidden("g.uri") | ||
) | ||
} | ||
| ||
/// The first photo of each gallery of `?1`. | ||
pub const COVERS_SQL: &str = "SELECT i.gallery, p.did, p.photo FROM gallery_item i | ||
JOIN photo p ON p.uri = i.item | ||
WHERE i.gallery IN (SELECT value FROM json_each(?1)) ORDER BY i.position, i.uri"; | ||
| ||
/// A gallery's place, as `places_sql` reads it. | ||
type Spot = ( | ||
String, | ||
String, | ||
Option<String>, | ||
String, | ||
Option<String>, | ||
Option<String>, | ||
Option<String>, | ||
); | ||
| ||
/// `value`, trimmed, where something is left. | ||
fn given(value: &Option<String>) -> Option<&str> { | ||
value.as_deref().map(str::trim).filter(|v| !v.is_empty()) | ||
} | ||
| ||
/// What a place is grouped by: its address, its name, or its city's | ||
/// cell, whichever it has first. | ||
fn key(spot: &Spot) -> Option<String> { | ||
let (_, _, name, cell, locality, region, country) = spot; | ||
let country = country.as_deref().and_then(country_code); | ||
let (locality, region) = (given(locality), given(region)); | ||
if locality.is_some() || region.is_some() || country.is_some() { | ||
let lower = |v: Option<&str>| v.map(str::to_lowercase).unwrap_or_default(); | ||
let country = country.unwrap_or_default(); | ||
return Some(format!("A:{country}|{}|{}", lower(region), lower(locality))); | ||
} | ||
if let Some(name) = given(name) { | ||
return Some(format!("N:{}", name.to_lowercase())); | ||
} | ||
city_of(cell).map(|c| format!("H:{c}")) | ||
} | ||
| ||
/// What a place is called on its tile: its locality, region and | ||
/// country's code; a country alone by its English name; or its own name. | ||
fn label(spot: &Spot) -> Option<String> { | ||
let (_, _, name, _, locality, region, country) = spot; | ||
let country = country.as_deref().and_then(country_code); | ||
let (locality, region) = (given(locality), given(region)); | ||
if locality.is_some() || region.is_some() { | ||
let parts: Vec<&str> = [locality, region, country.as_deref()] | ||
.into_iter() | ||
.flatten() | ||
.collect(); | ||
return Some(parts.join(", ")); | ||
} | ||
if let Some(code) = country { | ||
let named = REGIONS | ||
.iter() | ||
.find(|(c, _)| *c == code) | ||
.map(|(_, n)| n.to_string()); | ||
return Some(named.unwrap_or(code)); | ||
} | ||
given(name).map(str::to_string) | ||
} | ||
| ||
/// Counts of each of a place's names or cells, in the order first seen. | ||
#[derive(Default, Clone)] | ||
struct Tally(Vec<(String, usize)>); | ||
| ||
impl Tally { | ||
fn add(&mut self, value: &str, n: usize) { | ||
match self.0.iter_mut().find(|(v, _)| v == value) { | ||
Some((_, count)) => *count += n, | ||
None => self.0.push((value.to_string(), n)), | ||
} | ||
} | ||
| ||
/// Each value, the most counted first, those counted alike as seen. | ||
fn ranked(&self) -> Vec<String> { | ||
let mut all = self.0.clone(); | ||
all.sort_by_key(|a| std::cmp::Reverse(a.1)); | ||
all.into_iter().map(|(v, _)| v).collect() | ||
} | ||
} | ||
| ||
/// A place: what its galleries call it and where they are, how many | ||
/// they are, and the latest of them, with when. | ||
#[derive(Default)] | ||
struct Place { | ||
names: Tally, | ||
cells: Tally, | ||
galleries: Vec<(String, String)>, | ||
country: String, | ||
region: String, | ||
locality: String, | ||
count: usize, | ||
} | ||
| ||
/// A place as listed: its name, its cell, how many galleries, all its | ||
/// cells, and its latest galleries. | ||
type Ranked = (String, String, usize, Vec<String>, Vec<String>); | ||
| ||
/// `social.grain.gallery.getLocations`: the places galleries were made | ||
/// at, the most galleries first; each of them only as a map plots it | ||
/// where `pins` asks, and otherwise the first thirty, with their cells | ||
/// and the covers of their latest galleries. | ||
pub fn get_locations(r: &Reader, p: &Params) -> Result<Value, Failure> { | ||
let pins = match p.get("pins") { | ||
None | Some("false") => false, | ||
Some("true") => true, | ||
Some(_) => return Err(Failure::invalid("pins must be a boolean")), | ||
}; | ||
let spots: Vec<Spot> = r.all(&places_sql(), [])?; | ||
let mut keys: Vec<String> = Vec::new(); | ||
let mut places: std::collections::HashMap<String, Place> = Default::default(); | ||
for spot in &spots { | ||
let (Some(key), Some(label)) = (key(spot), label(spot)) else { | ||
continue; | ||
}; | ||
let place = places.entry(key.clone()).or_insert_with(|| { | ||
keys.push(key); | ||
Place { | ||
country: spot.6.as_deref().and_then(country_code).unwrap_or_default(), | ||
region: given(&spot.5).unwrap_or_default().to_lowercase(), | ||
locality: given(&spot.4).unwrap_or_default().to_lowercase(), | ||
..Place::default() | ||
} | ||
}); | ||
place.count += 1; | ||
if place.galleries.len() < THUMBS { | ||
place.galleries.push((spot.0.clone(), spot.1.clone())); | ||
} | ||
place.names.add(&label, 1); | ||
place.cells.add(&spot.3, 1); | ||
} | ||
// A place with no region, as one geocoder gives "Lisbon, PT" and | ||
// another "Lisbon, Lisbon, PT", is folded into the one place of its | ||
// locality and country that has one; where there are more, as | ||
// Portland is in Oregon and in Maine, it stands alone. | ||
for key in keys.clone() { | ||
let Some(place) = places.get(&key) else { | ||
continue; | ||
}; | ||
if !place.region.is_empty() || place.locality.is_empty() { | ||
continue; | ||
} | ||
let twins: Vec<&String> = keys | ||
.iter() | ||
.filter(|k| { | ||
places.get(*k).is_some_and(|o| { | ||
!o.region.is_empty() | ||
&& o.locality == place.locality | ||
&& o.country == place.country | ||
}) | ||
}) | ||
.collect(); | ||
let [into] = twins[..] else { continue }; | ||
let into = into.clone(); | ||
let place = places.remove(&key).expect("present"); | ||
let twin = places.get_mut(&into).expect("present"); | ||
twin.count += place.count; | ||
for (name, n) in &place.names.0 { | ||
twin.names.add(name, *n); | ||
} | ||
for (cell, n) in &place.cells.0 { | ||
twin.cells.add(cell, *n); | ||
} | ||
twin.galleries.extend(place.galleries); | ||
twin.galleries.sort_by(|a, b| b.1.cmp(&a.1)); | ||
twin.galleries.truncate(THUMBS); | ||
} | ||
let mut ranked: Vec<Ranked> = keys | ||
.iter() | ||
.filter_map(|k| places.get(k)) | ||
.filter_map(|place| { | ||
// The name most of its galleries give it, the first given | ||
// where two are given alike. | ||
let mut best: Option<&(String, usize)> = None; | ||
for named in &place.names.0 { | ||
if best.is_none_or(|b| named.1 > b.1) { | ||
best = Some(named); | ||
} | ||
} | ||
let cells = place.cells.ranked(); | ||
let (name, cell) = (best?.0.clone(), cells.first()?.clone()); | ||
let galleries = place.galleries.iter().map(|(u, _)| u.clone()).collect(); | ||
Some((name, cell, place.count, cells, galleries)) | ||
}) | ||
.collect(); | ||
ranked.sort_by(|a, b| { | ||
b.2.cmp(&a.2) | ||
.then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())) | ||
.then_with(|| a.0.cmp(&b.0)) | ||
}); | ||
if pins { | ||
let pins: Vec<Value> = ranked | ||
.iter() | ||
.map(|(name, cell, count, _, _)| json!({"name": name, "h3Index": cell, "galleryCount": count})) | ||
.collect(); | ||
return Ok(json!({ "locations": pins })); | ||
} | ||
ranked.truncate(PLACES); | ||
let uris: Vec<&str> = ranked | ||
.iter() | ||
.flat_map(|p| p.4.iter().map(String::as_str)) | ||
.collect(); | ||
let mut covers: std::collections::HashMap<String, String> = Default::default(); | ||
for (gallery, did, photo) in r.all::<(String, String, String)>(COVERS_SQL, [list(&uris)])? { | ||
let blob: Value = serde_json::from_str(&photo).unwrap_or_default(); | ||
if let (false, Some(url)) = ( | ||
covers.contains_key(&gallery), | ||
r.cdn.url(&did, &blob, THUMBNAIL), | ||
) { | ||
covers.insert(gallery, url); | ||
} | ||
} | ||
let locations: Vec<Value> = ranked | ||
.into_iter() | ||
.map(|(name, cell, count, cells, galleries)| { | ||
let thumbs: Vec<&String> = galleries.iter().filter_map(|g| covers.get(g)).collect(); | ||
json!({"name": name, "h3Index": cell, "galleryCount": count, "h3Cells": cells, "thumbs": thumbs}) | ||
}) | ||
.collect(); | ||
Ok(json!({ "locations": locations })) | ||
} | ||
| ||
#[cfg(test)] | ||
pub fn queries() -> Vec<String> { | ||
use super::feed::filtered_sql; | ||
let (named, _) = by_name("The Space Needle, Seattle, Washington, US"); | ||
vec![filtered_sql(&named), filtered_sql(CELL_FILTER)] | ||
vec![ | ||
filtered_sql(&named), | ||
filtered_sql(CELL_FILTER), | ||
places_sql(), | ||
COVERS_SQL.into(), | ||
] | ||
} | ||
| ||
#[cfg(test)] | ||
crates/grain-serve/src/read/mod.rs+29−6
| @@ -22,6 +22,7 @@ pub mod graph; | ||
pub mod location; | ||
pub mod moderation; | ||
pub mod place; | ||
pub mod search; | ||
pub mod story; | ||
pub mod views; | ||
| ||
| @@ -71,10 +72,26 @@ pub const METHODS: &[(&str, Method)] = &[ | ||
("social.grain.story.getAuthors", story::get_authors), | ||
("social.grain.feed.describeFeeds", feed::describe_feeds), | ||
("social.grain.feed.getFeed", feed::get_feed), | ||
("social.grain.actor.searchActors", search::search_actors), | ||
( | ||
"social.grain.actor.getSuggestedFollows", | ||
actor::get_suggested_follows, | ||
), | ||
( | ||
"social.grain.gallery.searchGalleries", | ||
search::search_galleries, | ||
), | ||
("social.grain.gallery.getCameras", camera::get_cameras), | ||
("social.grain.gallery.getLocations", location::get_locations), | ||
]; | ||
| ||
/// `router` with each of [`METHODS`] at `/xrpc/<nsid>`. | ||
/// `router` with each of [`METHODS`] at `/xrpc/<nsid>`, and typeahead, | ||
/// which asks Bluesky's too ([`search::typeahead`]). | ||
pub fn routes(router: Router<Arc<App>>) -> Router<Arc<App>> { | ||
let router = router.route( | ||
"/xrpc/social.grain.actor.searchActorsTypeahead", | ||
get(search::typeahead), | ||
); | ||
METHODS.iter().fold(router, |router, &(nsid, method)| { | ||
let path = format!("/xrpc/{nsid}"); | ||
router.route( | ||
| @@ -105,11 +122,7 @@ async fn answer( | ||
why, | ||
)); | ||
}; | ||
let params = Params( | ||
Query::try_from_uri(&uri) | ||
.map(|Query(q)| q) | ||
.unwrap_or_default(), | ||
); | ||
let params = params(&uri); | ||
let viewer = crate::viewer::viewer(&app, &headers, nsid).await?; | ||
index.read(|db| { | ||
let reader = Reader { | ||
| @@ -122,6 +135,15 @@ async fn answer( | ||
}) | ||
} | ||
| ||
/// The parameters `uri` gives, none where it gives none that parse. | ||
fn params(uri: &Uri) -> Params { | ||
Params( | ||
Query::try_from_uri(uri) | ||
.map(|Query(q)| q) | ||
.unwrap_or_default(), | ||
) | ||
} | ||
| ||
/// A query's parameters, as its URL names them. | ||
#[derive(Debug, Default, Clone)] | ||
pub struct Params(pub Vec<(String, String)>); | ||
| @@ -296,6 +318,7 @@ columns!(A 0); | ||
columns!(A 0, B 1); | ||
columns!(A 0, B 1, C 2); | ||
columns!(A 0, B 1, C 2, D 3, E 4); | ||
columns!(A 0, B 1, C 2, D 3, E 4, F 5, G 6); | ||
| ||
#[cfg(test)] | ||
mod tests; | ||
crates/grain-serve/src/read/search.rsnew+846−0
846 lines changed: not loaded until asked for.
crates/grain-serve/src/read/tests.rs+1−0
| @@ -13,6 +13,7 @@ fn the_reads_read_by_index() { | ||
queries.extend(story::queries()); | ||
queries.extend(feed::queries()); | ||
queries.extend(views::queries()); | ||
queries.extend(search::queries()); | ||
index.read(|db| { | ||
sized(db); | ||
for sql in &queries { | ||
crates/grain-serve/src/lib.rs+26−0
| @@ -40,6 +40,11 @@ pub struct Settings { | ||
/// imgproxy, and the hex key and salt it signs paths with | ||
/// ([`cdn::Cdn`]). None draws images from Bluesky's CDN. | ||
pub cdn: [Option<String>; 3], | ||
/// `GRAIN_BSKY_APPVIEW`: the Bluesky appview whose typeahead | ||
/// `searchActorsTypeahead` fills out its own with, a URL, or `1` for | ||
/// [`read::search::BSKY_APPVIEW`]. None, as when it is unset, empty | ||
/// or `0`, asks none, as tests and dev run. | ||
pub bsky_appview: Option<String>, | ||
} | ||
| ||
impl Settings { | ||
| @@ -55,6 +60,11 @@ impl Settings { | ||
Some(url) => Some(url.into()), | ||
}, | ||
cdn: ["GRAIN_CDN_URL", "GRAIN_CDN_KEY", "GRAIN_CDN_SALT"].map(set), | ||
bsky_appview: match set("GRAIN_BSKY_APPVIEW").as_deref() { | ||
None | Some("0") => None, | ||
Some("1") => Some(read::search::BSKY_APPVIEW.into()), | ||
Some(url) => Some(url.trim_end_matches('/').into()), | ||
}, | ||
} | ||
} | ||
| ||
| @@ -100,6 +110,7 @@ impl Settings { | ||
index, | ||
did, | ||
cdn, | ||
bsky_appview: self.bsky_appview.clone(), | ||
}) | ||
} | ||
} | ||
| @@ -114,6 +125,8 @@ pub struct App { | ||
pub did: Option<String>, | ||
/// Where views send clients for images. | ||
pub cdn: cdn::Cdn, | ||
/// The Bluesky appview typeahead asks too, if any. | ||
pub bsky_appview: Option<String>, | ||
} | ||
| ||
impl App { | ||
| @@ -125,6 +138,7 @@ impl App { | ||
index: None, | ||
did: None, | ||
cdn: cdn::Cdn::default(), | ||
bsky_appview: None, | ||
} | ||
} | ||
} | ||
| @@ -205,8 +219,20 @@ mod tests { | ||
"GRAIN_INDEX" => Some("/data/index.db".into()), | ||
"GRAIN_PUBLIC_URL" => Some("https://grain.social".into()), | ||
"GRAIN_CDN_URL" => Some("https://cdn.grain.social".into()), | ||
"GRAIN_BSKY_APPVIEW" => Some("1".into()), | ||
_ => None, | ||
}); | ||
assert_eq!( | ||
set.bsky_appview.as_deref(), | ||
Some(read::search::BSKY_APPVIEW) | ||
); | ||
let named = Settings::from_vars(|name| { | ||
(name == "GRAIN_BSKY_APPVIEW").then(|| "http://127.0.0.1:2600/".to_string()) | ||
}); | ||
assert_eq!(named.bsky_appview.as_deref(), Some("http://127.0.0.1:2600")); | ||
let off = | ||
Settings::from_vars(|name| (name == "GRAIN_BSKY_APPVIEW").then(|| "0".to_string())); | ||
assert_eq!(off.bsky_appview, None); | ||
assert_eq!(set.cdn[0].as_deref(), Some("https://cdn.grain.social")); | ||
assert!( | ||
set.app(Ctx::new(Env::from_vars(|_| None))).is_err(), | ||
crates/grain-serve/tests/index_records.rs+1−0
crates/grain-serve/tests/jetstream.rs+6−2
crates/grain-serve/tests/read.rs+447−0
447 lines changed: not loaded until asked for.