chore: source formatting and spec allignment

This commit is contained in:
2026-07-18 14:20:23 +02:00
parent a594b99e90
commit 16a210c0ad
119 changed files with 8868 additions and 5250 deletions

View File

@@ -1,5 +1,7 @@
use rust_stemmers::{Algorithm, Stemmer};
use rusqlite::Connection;
use rust_stemmers::{Algorithm, Stemmer};
use crate::util::calendar_range_unix_ms;
/// Create FTS5 virtual tables at runtime (not in migrations per spec).
///
@@ -22,7 +24,7 @@ pub fn ensure_fts_tables(conn: &Connection) -> rusqlite::Result<()> {
caption,
original_name,
tags
);"
);",
)?;
Ok(())
}
@@ -82,6 +84,10 @@ pub struct MediaTranslationFts {
/// Index a post in the FTS table with separate columns per spec.
///
/// Translation titles go to the title column, excerpts to excerpt, content to content.
#[expect(
clippy::too_many_arguments,
reason = "FTS columns mirror the persisted post fields"
)]
pub fn index_post(
conn: &Connection,
post_id: &str,
@@ -137,6 +143,10 @@ pub fn index_post(
/// Index a media item in the FTS table with separate columns per spec.
///
/// Translation titles go to the title column, alts to alt, captions to caption.
#[expect(
clippy::too_many_arguments,
reason = "FTS columns mirror the persisted media fields"
)]
pub fn index_media(
conn: &Connection,
media_id: &str,
@@ -209,14 +219,15 @@ pub fn remove_media_from_index(conn: &Connection, media_id: &str) -> rusqlite::R
}
/// Search posts by full-text query. Returns matching post IDs.
pub fn search_posts(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
pub fn search_posts(
conn: &Connection,
query: &str,
language: &str,
) -> rusqlite::Result<Vec<String>> {
let stemmed = stem_text(query, language);
let mut stmt = conn.prepare(
"SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank"
)?;
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
row.get::<_, String>(0)
})?;
let mut stmt =
conn.prepare("SELECT post_id FROM posts_fts WHERE posts_fts MATCH ?1 ORDER BY rank")?;
let rows = stmt.query_map(rusqlite::params![stemmed], |row| row.get::<_, String>(0))?;
rows.collect()
}
@@ -255,16 +266,28 @@ pub fn search_posts_filtered(
// Get FTS matches first
let fts_ids = search_posts(conn, query, language)?;
if fts_ids.is_empty() {
return Ok(SearchResults { post_ids: vec![], total: 0, offset: filters.offset.unwrap_or(0), limit: filters.limit.unwrap_or(0) });
return Ok(SearchResults {
post_ids: vec![],
total: 0,
offset: filters.offset.unwrap_or(0),
limit: filters.limit.unwrap_or(0),
});
}
// Apply filters by querying posts table
let placeholders: Vec<String> = fts_ids.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect();
let placeholders: Vec<String> = fts_ids
.iter()
.enumerate()
.map(|(i, _)| format!("?{}", i + 1))
.collect();
let mut sql = format!(
"SELECT id FROM posts WHERE id IN ({}) ",
placeholders.join(",")
);
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = fts_ids.iter().map(|id| Box::new(id.clone()) as Box<dyn rusqlite::types::ToSql>).collect();
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = fts_ids
.iter()
.map(|id| Box::new(id.clone()) as Box<dyn rusqlite::types::ToSql>)
.collect();
let mut param_idx = fts_ids.len() + 1;
if let Some(status) = filters.status {
@@ -296,29 +319,21 @@ pub fn search_posts_filtered(
}
if let Some(year) = filters.year {
// Filter by year from created_at (unix ms)
let start = chrono::NaiveDate::from_ymd_opt(year, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
let end = chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1));
let (start, end) =
calendar_range_unix_ms(year, filters.month).ok_or(rusqlite::Error::InvalidQuery)?;
sql.push_str(&format!(
"AND created_at >= ?{param_idx} AND created_at < ?{} ",
param_idx + 1
));
params.push(Box::new(start));
params.push(Box::new(end));
param_idx += 2;
}
if let Some(month) = filters.month {
if let Some(year) = filters.year {
let (end_year, end_month) = if month == 12 { (year + 1, 1) } else { (year, month as i32 + 1) };
let start = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
let end = chrono::NaiveDate::from_ymd_opt(end_year, end_month as u32, 1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis();
sql.push_str(&format!("AND created_at >= ?{param_idx} AND created_at < ?{} ", param_idx + 1));
params.push(Box::new(start));
params.push(Box::new(end));
param_idx += 2;
}
}
if let Some(lang) = filters.language {
sql.push_str(&format!("AND (language = ?{param_idx} OR language IS NULL) "));
sql.push_str(&format!(
"AND (language = ?{param_idx} OR language IS NULL) "
));
params.push(Box::new(lang.to_string()));
param_idx += 1;
}
@@ -349,7 +364,8 @@ pub fn search_posts_filtered(
let count_sql = sql.replace("SELECT id FROM posts", "SELECT COUNT(*) FROM posts");
let total: usize = {
let mut stmt = conn.prepare(&count_sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|p| p.as_ref()).collect();
stmt.query_row(params_refs.as_slice(), |row| row.get::<_, usize>(0))?
};
@@ -365,22 +381,26 @@ pub fn search_posts_filtered(
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(params_refs.as_slice(), |row| {
row.get::<_, String>(0)
})?;
let rows = stmt.query_map(params_refs.as_slice(), |row| row.get::<_, String>(0))?;
let post_ids: Vec<String> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(SearchResults { post_ids, total, offset, limit })
Ok(SearchResults {
post_ids,
total,
offset,
limit,
})
}
/// Search media by full-text query. Returns matching media IDs.
pub fn search_media(conn: &Connection, query: &str, language: &str) -> rusqlite::Result<Vec<String>> {
pub fn search_media(
conn: &Connection,
query: &str,
language: &str,
) -> rusqlite::Result<Vec<String>> {
let stemmed = stem_text(query, language);
let mut stmt = conn.prepare(
"SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank"
)?;
let rows = stmt.query_map(rusqlite::params![stemmed], |row| {
row.get::<_, String>(0)
})?;
let mut stmt =
conn.prepare("SELECT media_id FROM media_fts WHERE media_fts MATCH ?1 ORDER BY rank")?;
let rows = stmt.query_map(rusqlite::params![stemmed], |row| row.get::<_, String>(0))?;
rows.collect()
}
@@ -526,7 +546,8 @@ mod tests {
language: "en".into(),
}],
"en",
).unwrap();
)
.unwrap();
let results = search_posts(db.conn(), "spider", "en").unwrap();
assert_eq!(results, vec!["post-1"]);
@@ -545,7 +566,8 @@ mod tests {
&["nature".into()],
&[],
"en",
).unwrap();
)
.unwrap();
let results = search_media(db.conn(), "sunset", "en").unwrap();
assert_eq!(results, vec!["media-1"]);
@@ -561,10 +583,7 @@ mod tests {
#[test]
fn remove_from_index() {
let db = setup();
index_post(
db.conn(), "p1", "Test", None, None,
&[], &[], &[], "en",
).unwrap();
index_post(db.conn(), "p1", "Test", None, None, &[], &[], &[], "en").unwrap();
assert_eq!(search_posts(db.conn(), "test", "en").unwrap().len(), 1);
remove_post_from_index(db.conn(), "p1").unwrap();
@@ -586,8 +605,13 @@ mod tests {
let db = setup();
// German post with English translation
index_post(
db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"),
&[], &[],
db.conn(),
"p1",
"Programmierung",
None,
Some("Deutsche Entwicklung"),
&[],
&[],
&[PostTranslationFts {
title: "English development programming".into(),
excerpt: None,
@@ -595,7 +619,8 @@ mod tests {
language: "en".into(),
}],
"de",
).unwrap();
)
.unwrap();
// Search with English stemming should find via English translation
let results = search_posts(db.conn(), "develop", "en").unwrap();
@@ -605,7 +630,18 @@ mod tests {
#[test]
fn search_by_title_field() {
let db = setup();
index_post(db.conn(), "p1", "Unique Title Here", None, Some("body text"), &[], &[], &[], "en").unwrap();
index_post(
db.conn(),
"p1",
"Unique Title Here",
None,
Some("body text"),
&[],
&[],
&[],
"en",
)
.unwrap();
// Search for title content
let results = search_posts(db.conn(), "unique", "en").unwrap();
@@ -615,7 +651,18 @@ mod tests {
#[test]
fn search_by_tags() {
let db = setup();
index_post(db.conn(), "p1", "Post", None, None, &["photography".into()], &[], &[], "en").unwrap();
index_post(
db.conn(),
"p1",
"Post",
None,
None,
&["photography".into()],
&[],
&[],
"en",
)
.unwrap();
let results = search_posts(db.conn(), "photography", "en").unwrap();
assert_eq!(results, vec!["p1"]);
@@ -624,7 +671,18 @@ mod tests {
#[test]
fn search_by_categories() {
let db = setup();
index_post(db.conn(), "p1", "Post", None, None, &[], &["article".into()], &[], "en").unwrap();
index_post(
db.conn(),
"p1",
"Post",
None,
None,
&[],
&["article".into()],
&[],
"en",
)
.unwrap();
let results = search_posts(db.conn(), "article", "en").unwrap();
assert_eq!(results, vec!["p1"]);
@@ -647,8 +705,30 @@ mod tests {
[],
).unwrap();
index_post(db.conn(), "post1", "Test Post", None, None, &[], &[], &[], "en").unwrap();
index_post(db.conn(), "post2", "Draft Post", None, None, &[], &[], &[], "en").unwrap();
index_post(
db.conn(),
"post1",
"Test Post",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
index_post(
db.conn(),
"post2",
"Draft Post",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
let filters = PostSearchFilters {
status: Some("published"),
@@ -670,19 +750,45 @@ mod tests {
// 2023-06-15 in unix ms
let ts_2023: i64 = 1686873600000;
db.conn().execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
db.conn()
.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('p2024', 'p1', 'Year 2024', 'y2024', 'draft', ?1, ?1)",
rusqlite::params![ts_2024],
).unwrap();
db.conn().execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
rusqlite::params![ts_2024],
)
.unwrap();
db.conn()
.execute(
"INSERT INTO posts (id, project_id, title, slug, status, created_at, updated_at)
VALUES ('p2023', 'p1', 'Year 2023', 'y2023', 'draft', ?1, ?1)",
rusqlite::params![ts_2023],
).unwrap();
rusqlite::params![ts_2023],
)
.unwrap();
index_post(db.conn(), "p2024", "Year 2024", None, None, &[], &[], &[], "en").unwrap();
index_post(db.conn(), "p2023", "Year 2023", None, None, &[], &[], &[], "en").unwrap();
index_post(
db.conn(),
"p2024",
"Year 2024",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
index_post(
db.conn(),
"p2023",
"Year 2023",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
let filters = PostSearchFilters {
year: Some(2024),
@@ -707,7 +813,18 @@ mod tests {
VALUES (?1, 'p1', 'Searchable', ?2, 'draft', ?3, ?3)",
rusqlite::params![id, slug, 1700000000000i64 - i as i64 * 1000],
).unwrap();
index_post(db.conn(), &id, "Searchable", None, None, &[], &[], &[], "en").unwrap();
index_post(
db.conn(),
&id,
"Searchable",
None,
None,
&[],
&[],
&[],
"en",
)
.unwrap();
}
let filters = PostSearchFilters {