Something went wrong. Try again.
Hangar is your Bluesky home base—built natively for Linux. ✈️ hangar.blue
libadwaita gtk4 bluesky flatpak gnome atprotocol linux atproto appimage rust
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366// SPDX-License-Identifier: MPL-2.0
use crate::atproto::{Embed, Post, Profile, ReplyContext, RepostReason};use crate::cache::{CacheDb, CacheError};use rusqlite::params;
/// Cache operations for postspub struct PostCache<'a> { db: &'a CacheDb,}
impl<'a> PostCache<'a> { pub fn new(db: &'a CacheDb) -> Self { Self { db } }
/// Store a single post (upserts) #[allow(dead_code)] pub fn store(&self, post: &Post) -> Result<(), CacheError> { let conn = self.db.conn(); let now = CacheDb::now();
// Serialize complex fields to JSON let embed_json = post.embed.as_ref().map(serde_json::to_string).transpose()?; let repost_reason_json = post .repost_reason .as_ref() .map(serde_json::to_string) .transpose()?; let reply_context_json = post .reply_context .as_ref() .map(serde_json::to_string) .transpose()?;
conn.execute( r#" INSERT INTO posts ( uri, cid, author_did, text, created_at, indexed_at, like_count, repost_count, reply_count, embed_json, repost_reason_json, reply_context_json, viewer_like, viewer_repost, fetched_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) ON CONFLICT(uri) DO UPDATE SET cid = excluded.cid, text = excluded.text, like_count = excluded.like_count, repost_count = excluded.repost_count, reply_count = excluded.reply_count, embed_json = excluded.embed_json, repost_reason_json = excluded.repost_reason_json, reply_context_json = excluded.reply_context_json, viewer_like = excluded.viewer_like, viewer_repost = excluded.viewer_repost, fetched_at = excluded.fetched_at "#, params![ post.uri, post.cid, post.author.did, post.text, post.created_at, post.indexed_at, post.like_count, post.repost_count, post.reply_count, embed_json, repost_reason_json, reply_context_json, post.viewer_like, post.viewer_repost, now, ], )?;
// Also store the author profile (minimal) Self::store_minimal_profile(&post.author, now, &conn)?;
Ok(()) }
/// Store multiple posts in a transaction pub fn store_batch(&self, posts: &[Post]) -> Result<(), CacheError> { let mut conn = self.db.conn(); let tx = conn.transaction()?; let now = CacheDb::now();
for post in posts { let embed_json = post.embed.as_ref().map(serde_json::to_string).transpose()?; let repost_reason_json = post .repost_reason .as_ref() .map(serde_json::to_string) .transpose()?; let reply_context_json = post .reply_context .as_ref() .map(serde_json::to_string) .transpose()?;
tx.execute( r#" INSERT INTO posts ( uri, cid, author_did, text, created_at, indexed_at, like_count, repost_count, reply_count, embed_json, repost_reason_json, reply_context_json, viewer_like, viewer_repost, fetched_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15) ON CONFLICT(uri) DO UPDATE SET cid = excluded.cid, text = excluded.text, like_count = excluded.like_count, repost_count = excluded.repost_count, reply_count = excluded.reply_count, embed_json = excluded.embed_json, repost_reason_json = excluded.repost_reason_json, reply_context_json = excluded.reply_context_json, viewer_like = excluded.viewer_like, viewer_repost = excluded.viewer_repost, fetched_at = excluded.fetched_at "#, params![ post.uri, post.cid, post.author.did, post.text, post.created_at, post.indexed_at, post.like_count, post.repost_count, post.reply_count, embed_json, repost_reason_json, reply_context_json, post.viewer_like, post.viewer_repost, now, ], )?;
// Store author profile Self::store_minimal_profile_tx(&post.author, now, &tx)?;
// Store repost_reason author if present if let Some(reason) = &post.repost_reason { Self::store_minimal_profile_tx(&reason.by, now, &tx)?; }
// Store reply_context authors if present if let Some(ctx) = &post.reply_context { Self::store_minimal_profile_tx(&ctx.parent_author, now, &tx)?; Self::store_minimal_profile_tx(&ctx.root_author, now, &tx)?; } }
tx.commit()?; Ok(()) }
/// Get post by URI #[allow(dead_code)] pub fn get(&self, uri: &str) -> Result<Post, CacheError> { let conn = self.db.conn();
let mut stmt = conn.prepare( r#" SELECT p.uri, p.cid, p.author_did, p.text, p.created_at, p.indexed_at, p.like_count, p.repost_count, p.reply_count, p.embed_json, p.repost_reason_json, p.reply_context_json, p.viewer_like, p.viewer_repost, pr.handle, pr.display_name, pr.avatar FROM posts p LEFT JOIN profiles pr ON p.author_did = pr.did WHERE p.uri = ? "#, )?;
let post = stmt .query_row([uri], Self::row_to_post) .map_err(|e| match e { rusqlite::Error::QueryReturnedNoRows => CacheError::NotFound, other => CacheError::Database(other), })?;
Ok(post) }
/// Get multiple posts by URIs (preserves order) #[allow(dead_code)] pub fn get_batch(&self, uris: &[String]) -> Result<Vec<Post>, CacheError> { if uris.is_empty() { return Ok(Vec::new()); }
let conn = self.db.conn();
// Build query with placeholders let placeholders: Vec<_> = (1..=uris.len()).map(|i| format!("?{}", i)).collect(); let query = format!( r#" SELECT p.uri, p.cid, p.author_did, p.text, p.created_at, p.indexed_at, p.like_count, p.repost_count, p.reply_count, p.embed_json, p.repost_reason_json, p.reply_context_json, p.viewer_like, p.viewer_repost, pr.handle, pr.display_name, pr.avatar FROM posts p LEFT JOIN profiles pr ON p.author_did = pr.did WHERE p.uri IN ({}) "#, placeholders.join(", ") );
let mut stmt = conn.prepare(&query)?;
// Convert URIs to params let params: Vec<&dyn rusqlite::ToSql> = uris.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
let mut rows = stmt.query(params.as_slice())?; let mut posts_map = std::collections::HashMap::new();
while let Some(row) = rows.next()? { let post = Self::row_to_post(row)?; posts_map.insert(post.uri.clone(), post); }
// Return in original order Ok(uris .iter() .filter_map(|uri| posts_map.remove(uri)) .collect()) }
/// Update viewer state only (after like/repost actions) #[allow(dead_code)] pub fn update_viewer_state( &self, uri: &str, viewer_like: Option<&str>, viewer_repost: Option<&str>, ) -> Result<(), CacheError> { let conn = self.db.conn();
conn.execute( r#" UPDATE posts SET viewer_like = ?, viewer_repost = ? WHERE uri = ? "#, params![viewer_like, viewer_repost, uri], )?;
Ok(()) }
/// Convert a database row to a Post fn row_to_post(row: &rusqlite::Row) -> Result<Post, rusqlite::Error> { let embed_json: Option<String> = row.get(9)?; let repost_reason_json: Option<String> = row.get(10)?; let reply_context_json: Option<String> = row.get(11)?;
let embed: Option<Embed> = embed_json .as_ref() .and_then(|j| serde_json::from_str(j).ok()); let repost_reason: Option<RepostReason> = repost_reason_json .as_ref() .and_then(|j| serde_json::from_str(j).ok()); let reply_context: Option<ReplyContext> = reply_context_json .as_ref() .and_then(|j| serde_json::from_str(j).ok());
Ok(Post { uri: row.get(0)?, cid: row.get(1)?, author: Profile { did: row.get(2)?, handle: row.get::<_, Option<String>>(14)?.unwrap_or_default(), display_name: row.get(15)?, avatar: row.get(16)?, banner: None, description: None, followers_count: None, following_count: None, posts_count: None, viewer_following: None, viewer_followed_by: None, // Not cached columns; moderation state loads with the live profile. viewer_muted: false, viewer_blocking: None, viewer_blocked_by: false, }, text: row.get(3)?, created_at: row.get(4)?, indexed_at: row.get(5)?, like_count: row.get(6)?, repost_count: row.get(7)?, reply_count: row.get(8)?, embed, viewer_like: row.get(12)?, viewer_repost: row.get(13)?, // Not a cached column; the menu falls back to offering Save. viewer_bookmarked: None, repost_reason, reply_context, }) }
/// Store a minimal profile (from post author) fn store_minimal_profile( profile: &Profile, now: i64, conn: &rusqlite::Connection, ) -> Result<(), CacheError> { conn.execute( r#" INSERT INTO profiles (did, handle, display_name, avatar, fetched_at, is_full) VALUES (?1, ?2, ?3, ?4, ?5, 0) ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, display_name = COALESCE(excluded.display_name, profiles.display_name), avatar = COALESCE(excluded.avatar, profiles.avatar), fetched_at = excluded.fetched_at WHERE profiles.is_full = 0 "#, params![ profile.did, profile.handle, profile.display_name, profile.avatar, now ], )?; Ok(()) }
/// Store a minimal profile in a transaction fn store_minimal_profile_tx( profile: &Profile, now: i64, tx: &rusqlite::Transaction, ) -> Result<(), CacheError> { tx.execute( r#" INSERT INTO profiles (did, handle, display_name, avatar, fetched_at, is_full) VALUES (?1, ?2, ?3, ?4, ?5, 0) ON CONFLICT(did) DO UPDATE SET handle = excluded.handle, display_name = COALESCE(excluded.display_name, profiles.display_name), avatar = COALESCE(excluded.avatar, profiles.avatar), fetched_at = excluded.fetched_at WHERE profiles.is_full = 0 "#, params![ profile.did, profile.handle, profile.display_name, profile.avatar, now ], )?; Ok(()) }}