From bddbe1b0e24c60697de20d15c7f0a039dafeacfd Mon Sep 17 00:00:00 2001 From: hitalin Date: Sat, 12 Sep 2026 16:37:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(db):=20=E3=82=AD=E3=83=A3=E3=83=83?= =?UTF-8?q?=E3=82=B7=E3=83=A5=E6=A8=AA=E6=96=AD=E6=A4=9C=E7=B4=A2=E3=81=AB?= =?UTF-8?q?=E6=8A=95=E7=A8=BF=E8=80=85=E3=81=A8=E6=B7=BB=E4=BB=98=E3=81=AE?= =?UTF-8?q?=E7=B5=9E=E3=82=8A=E8=BE=BC=E3=81=BF=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20(notedeck#945)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit search_cached_notes_across の引数を CachedSearchOptions にまとめ、 author (name / name@host、大文字小文字を区別しない。ローカルユーザーの host は 取得元サーバーで補う) と has_files (json_array_length) を追加する。 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0129MFAxbj2vmL1Gu8FsgHLk --- src/db.rs | 190 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 178 insertions(+), 12 deletions(-) diff --git a/src/db.rs b/src/db.rs index be36737..dceb9f6 100644 --- a/src/db.rs +++ b/src/db.rs @@ -149,6 +149,23 @@ pub struct Database { reader: Mutex, } +/// キャッシュ横断検索の条件 (notedeck#945)。`Default` は「絞り込みなし」。 +#[derive(Debug, Clone, Copy, Default)] +pub struct CachedSearchOptions<'a> { + /// 本文の検索語。3 文字以上は FTS (trigram)、それ未満は LIKE、空なら全件 + pub query: &'a str, + pub limit: i64, + /// created_at の下限 / 上限 (ISO 8601、両端含む) + pub since_date: Option<&'a str>, + pub until_date: Option<&'a str>, + /// true なら古い順 + pub ascending: bool, + /// 投稿者 `name` または `name@host` (大文字小文字を区別しない) + pub author: Option<&'a str>, + /// Some(true) = 添付あり、Some(false) = 添付なし + pub has_files: Option, +} + impl Database { /// デフォルトの eviction policy で DB を開く。 後方互換性のために維持。 pub fn open(path: &Path) -> Result { @@ -829,11 +846,14 @@ impl Database { ) -> Result, NoteDeckError> { self.search_cached_notes_across( &[account_id], - query, - limit, - since_date, - until_date, - ascending, + &CachedSearchOptions { + query, + limit, + since_date, + until_date, + ascending, + ..Default::default() + }, ) } @@ -843,15 +863,20 @@ impl Database { pub fn search_cached_notes_across( &self, account_ids: &[&str], - query: &str, - limit: i64, - since_date: Option<&str>, - until_date: Option<&str>, - ascending: bool, + opts: &CachedSearchOptions<'_>, ) -> Result, NoteDeckError> { if account_ids.is_empty() { return Ok(Vec::new()); } + let CachedSearchOptions { + query, + limit, + since_date, + until_date, + ascending, + author, + has_files, + } = *opts; let conn = self.lock_read()?; let order = if ascending { "ASC" } else { "DESC" }; let has_query = !query.is_empty(); @@ -895,6 +920,36 @@ impl Database { param_idx += 1; } + // 投稿者: `name` または `name@host`。host 省略時は取得元サーバーを問わず + // username だけで一致させる。ローカルユーザーは user.host が null なので、 + // host 指定時は取得元 (server_host) で補う + let author_parts = author.map(|a| { + let a = a.trim().trim_start_matches('@'); + match a.split_once('@') { + Some((name, host)) => (name.to_lowercase(), Some(host.to_lowercase())), + None => (a.to_lowercase(), None), + } + }); + if let Some((_, host)) = &author_parts { + conditions.push(format!( + "LOWER(json_extract(nc.note_json, '$.user.username')) = ?{param_idx}" + )); + param_idx += 1; + if host.is_some() { + conditions.push(format!( + "LOWER(COALESCE(json_extract(nc.note_json, '$.user.host'), nc.server_host)) = ?{param_idx}" + )); + param_idx += 1; + } + } + if let Some(with_files) = has_files { + conditions.push(if with_files { + "json_array_length(nc.note_json, '$.files') > 0".to_string() + } else { + "json_array_length(nc.note_json, '$.files') = 0".to_string() + }); + } + let sql = format!( "SELECT nc.note_json FROM notes_cache nc WHERE {} ORDER BY nc.created_at {order} LIMIT ?{param_idx}", conditions.join(" AND "), @@ -918,6 +973,12 @@ impl Database { if let Some(d) = until_date { dynamic_params.push(Box::new(d.to_string())); } + if let Some((name, host)) = author_parts { + dynamic_params.push(Box::new(name)); + if let Some(host) = host { + dynamic_params.push(Box::new(host)); + } + } dynamic_params.push(Box::new(limit)); let param_refs: Vec<&dyn rusqlite::types::ToSql> = @@ -4019,6 +4080,97 @@ mod tests { assert_eq!(out.notes.len(), 1); } + #[test] + fn search_cached_notes_across_filters_by_author_and_files() { + let (_dir, db) = temp_db(); + let mut by_alice_local = variant("n1", "acc-1", "a.example", None); + by_alice_local.user.username = "Alice".to_string(); + by_alice_local.user.host = None; + let mut by_alice_remote = variant("n2", "acc-2", "b.example", None); + by_alice_remote.user.username = "alice".to_string(); + by_alice_remote.user.host = Some("a.example".to_string()); + by_alice_remote + .files + .push(crate::models::NormalizedDriveFile { + id: "f1".to_string(), + name: "img.png".to_string(), + file_type: "image/png".to_string(), + url: "https://b.example/f1".to_string(), + thumbnail_url: None, + size: 1, + is_sensitive: false, + width: None, + height: None, + blurhash: None, + }); + let mut by_bob = variant("n3", "acc-1", "a.example", None); + by_bob.user.username = "bob".to_string(); + db.ingest_notes(&[by_alice_local, by_alice_remote, by_bob], &tk("home")) + .unwrap(); + let accounts = ["acc-1", "acc-2"]; + let base = CachedSearchOptions { + limit: 10, + ..Default::default() + }; + + // username だけ: 取得元を問わず、大文字小文字も区別しない + let alice = db + .search_cached_notes_across( + &accounts, + &CachedSearchOptions { + author: Some("@alice"), + ..base + }, + ) + .unwrap(); + assert_eq!(alice.len(), 2); + + // name@host: ローカルユーザー (host null) は取得元サーバーで補う + let alice_at_a = db + .search_cached_notes_across( + &accounts, + &CachedSearchOptions { + author: Some("alice@A.example"), + ..base + }, + ) + .unwrap(); + assert_eq!(alice_at_a.len(), 2); + let alice_at_b = db + .search_cached_notes_across( + &accounts, + &CachedSearchOptions { + author: Some("alice@b.example"), + ..base + }, + ) + .unwrap(); + assert!(alice_at_b.is_empty()); + + // 添付の有無 + let with_files = db + .search_cached_notes_across( + &accounts, + &CachedSearchOptions { + has_files: Some(true), + ..base + }, + ) + .unwrap(); + assert_eq!(with_files.len(), 1); + assert_eq!(with_files[0].id, "n2"); + let without_files = db + .search_cached_notes_across( + &accounts, + &CachedSearchOptions { + has_files: Some(false), + ..base + }, + ) + .unwrap(); + assert_eq!(without_files.len(), 2); + } + #[test] fn search_cached_notes_across_returns_variants_of_all_accounts() { let (_dir, db) = temp_db(); @@ -4038,14 +4190,28 @@ mod tests { db.ingest_notes(&[a, b, other], &tk("home")).unwrap(); let hits = db - .search_cached_notes_across(&["acc-1", "acc-2"], "identity", 10, None, None, false) + .search_cached_notes_across( + &["acc-1", "acc-2"], + &CachedSearchOptions { + query: "identity", + limit: 10, + ..Default::default() + }, + ) .unwrap(); assert_eq!(hits.len(), 2); assert!(hits .iter() .all(|n| n.identity == "https://o.example/notes/z")); assert!(db - .search_cached_notes_across(&[], "identity", 10, None, None, false) + .search_cached_notes_across( + &[], + &CachedSearchOptions { + query: "identity", + limit: 10, + ..Default::default() + }, + ) .unwrap() .is_empty()); // 単一アカウント版は横断版の薄いラッパ From 20b74053f2710df603ba64f26fd769ba925ee54a Mon Sep 17 00:00:00 2001 From: hitalin Date: Sat, 12 Sep 2026 17:05:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(db):=20=E3=82=AD=E3=83=A3=E3=83=83?= =?UTF-8?q?=E3=82=B7=E3=83=A5=E6=A8=AA=E6=96=AD=E6=A4=9C=E7=B4=A2=E3=81=AB?= =?UTF-8?q?=20public=5Fonly=20=E3=82=92=E8=B6=B3=E3=81=99=20(notedeck#947)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI など第三者に見せる面で、フォロワー限定 / ダイレクトを索引から除くための 条件。visibility = 'public' だけを返す。 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0129MFAxbj2vmL1Gu8FsgHLk --- src/db.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/db.rs b/src/db.rs index dceb9f6..d2be3e8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -164,6 +164,8 @@ pub struct CachedSearchOptions<'a> { pub author: Option<&'a str>, /// Some(true) = 添付あり、Some(false) = 添付なし pub has_files: Option, + /// true なら visibility が public のノートだけ (AI など第三者に見せる面用) + pub public_only: bool, } impl Database { @@ -876,6 +878,7 @@ impl Database { ascending, author, has_files, + public_only, } = *opts; let conn = self.lock_read()?; let order = if ascending { "ASC" } else { "DESC" }; @@ -949,6 +952,9 @@ impl Database { "json_array_length(nc.note_json, '$.files') = 0".to_string() }); } + if public_only { + conditions.push("json_extract(nc.note_json, '$.visibility') = 'public'".to_string()); + } let sql = format!( "SELECT nc.note_json FROM notes_cache nc WHERE {} ORDER BY nc.created_at {order} LIMIT ?{param_idx}", @@ -4171,6 +4177,41 @@ mod tests { assert_eq!(without_files.len(), 2); } + #[test] + fn search_cached_notes_across_public_only_drops_private_notes() { + let (_dir, db) = temp_db(); + let public = variant("n1", "acc-1", "a.example", None); + let mut followers = variant("n2", "acc-1", "a.example", None); + followers.visibility = "followers".to_string(); + let mut specified = variant("n3", "acc-1", "a.example", None); + specified.visibility = "specified".to_string(); + db.ingest_notes(&[public, followers, specified], &tk("home")) + .unwrap(); + + let all = db + .search_cached_notes_across( + &["acc-1"], + &CachedSearchOptions { + limit: 10, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(all.len(), 3); + let only_public = db + .search_cached_notes_across( + &["acc-1"], + &CachedSearchOptions { + limit: 10, + public_only: true, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(only_public.len(), 1); + assert_eq!(only_public[0].id, "n1"); + } + #[test] fn search_cached_notes_across_returns_variants_of_all_accounts() { let (_dir, db) = temp_db();