594 lines
19 KiB
Rust
594 lines
19 KiB
Rust
use crate::db::pool::{Connection, params};
|
|
use crate::queries::sqlite::queries::db_contacts::CONTACT_SELECT_COLS;
|
|
use serde::Serialize;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct KnownAddress {
|
|
pub email: String,
|
|
pub name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct Contact {
|
|
pub id: String,
|
|
pub email: String,
|
|
pub name: String,
|
|
pub company: String,
|
|
pub notes: String,
|
|
pub phone: String,
|
|
pub job_title: String,
|
|
pub address: String,
|
|
pub website: String,
|
|
pub birthday: String,
|
|
pub emails: Vec<String>,
|
|
pub phones: Vec<String>,
|
|
pub addresses: Vec<String>,
|
|
pub is_favorite: bool,
|
|
pub last_contacted: Option<String>,
|
|
pub contact_count: i64,
|
|
pub source: String,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
pub has_photo: bool,
|
|
}
|
|
|
|
fn row_to_contact(row: &crate::db::pool::Row<'_>) -> Result<Contact, crate::db::pool::DbError> {
|
|
Ok(Contact {
|
|
id: row.get(0)?,
|
|
email: row.get(1)?,
|
|
name: row.get(2)?,
|
|
company: row.get(3)?,
|
|
notes: row.get(4)?,
|
|
phone: row.get(5)?,
|
|
job_title: row.get(6)?,
|
|
address: row.get(7)?,
|
|
website: row.get(8)?,
|
|
birthday: row.get(9)?,
|
|
emails: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(16)?).unwrap_or_default(),
|
|
phones: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(17)?).unwrap_or_default(),
|
|
addresses: serde_json::from_str::<Vec<String>>(&row.get::<_, String>(18)?)
|
|
.unwrap_or_default(),
|
|
is_favorite: row.get::<_, i32>(10)? != 0,
|
|
last_contacted: row.get(11)?,
|
|
contact_count: row.get(12)?,
|
|
source: row.get(13)?,
|
|
created_at: row.get(14)?,
|
|
updated_at: row.get(15)?,
|
|
has_photo: row.get::<_, i32>(19)? != 0,
|
|
})
|
|
}
|
|
|
|
fn escape_like(term: &str) -> String {
|
|
term.replace('!', "!!")
|
|
.replace('%', "!%")
|
|
.replace('_', "!_")
|
|
}
|
|
|
|
pub fn upsert_contact(conn: &Connection, contact: &Contact) -> Result<(), String> {
|
|
conn.execute(
|
|
crate::dbq!(db_contacts::Q_001),
|
|
params![
|
|
contact.id,
|
|
contact.email,
|
|
contact.name,
|
|
contact.company,
|
|
contact.notes,
|
|
contact.phone,
|
|
contact.job_title,
|
|
contact.address,
|
|
contact.website,
|
|
contact.birthday,
|
|
contact.is_favorite as i32,
|
|
contact.last_contacted,
|
|
contact.contact_count,
|
|
contact.source,
|
|
contact.created_at,
|
|
contact.updated_at,
|
|
serde_json::to_string(&contact.emails).unwrap_or_else(|_| "[]".to_string()),
|
|
serde_json::to_string(&contact.phones).unwrap_or_else(|_| "[]".to_string()),
|
|
serde_json::to_string(&contact.addresses).unwrap_or_else(|_| "[]".to_string()),
|
|
],
|
|
)
|
|
.map_err(|e| format!("Failed to upsert contact: {e}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn get_contact(conn: &Connection, id: &str) -> Result<Option<Contact>, String> {
|
|
let sql =
|
|
crate::dbq!(db_contacts::FORMAT_001).replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS);
|
|
let result = conn.query_row(&sql, params![id], row_to_contact);
|
|
|
|
match result {
|
|
Ok(c) => Ok(Some(c)),
|
|
Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None),
|
|
Err(e) => Err(format!("Failed to get contact: {e}")),
|
|
}
|
|
}
|
|
|
|
pub fn get_contact_by_email(conn: &Connection, email: &str) -> Result<Option<Contact>, String> {
|
|
let sql =
|
|
crate::dbq!(db_contacts::FORMAT_002).replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS);
|
|
let result = conn.query_row(&sql, params![email], row_to_contact);
|
|
|
|
match result {
|
|
Ok(c) => Ok(Some(c)),
|
|
Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None),
|
|
Err(e) => Err(format!("Failed to get contact by email: {e}")),
|
|
}
|
|
}
|
|
|
|
pub fn list_contacts(
|
|
conn: &Connection,
|
|
search: Option<&str>,
|
|
limit: u32,
|
|
offset: u32,
|
|
) -> Result<Vec<Contact>, String> {
|
|
let (sql, has_search) = match search {
|
|
Some(q) if !q.is_empty() => {
|
|
let escaped = escape_like(q);
|
|
let pattern = format!("%{escaped}%");
|
|
let sql = crate::dbq!(db_contacts::FORMAT_003)
|
|
.replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS);
|
|
|
|
drop(pattern);
|
|
(sql, Some(escaped))
|
|
}
|
|
_ => {
|
|
let sql = crate::dbq!(db_contacts::FORMAT_004)
|
|
.replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS);
|
|
(sql, None)
|
|
}
|
|
};
|
|
|
|
match has_search {
|
|
Some(escaped) => {
|
|
let pattern = format!("%{escaped}%");
|
|
let mut stmt = conn
|
|
.prepare(&sql)
|
|
.map_err(|e| format!("Failed to prepare list_contacts: {e}"))?;
|
|
let rows = stmt
|
|
.query_map(params![pattern, limit, offset], row_to_contact)
|
|
.map_err(|e| format!("Failed to query contacts: {e}"))?;
|
|
let mut contacts = Vec::new();
|
|
for row in rows {
|
|
contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?);
|
|
}
|
|
Ok(contacts)
|
|
}
|
|
None => {
|
|
let mut stmt = conn
|
|
.prepare(&sql)
|
|
.map_err(|e| format!("Failed to prepare list_contacts: {e}"))?;
|
|
let rows = stmt
|
|
.query_map(params![limit, offset], row_to_contact)
|
|
.map_err(|e| format!("Failed to query contacts: {e}"))?;
|
|
let mut contacts = Vec::new();
|
|
for row in rows {
|
|
contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?);
|
|
}
|
|
Ok(contacts)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn delete_contact(conn: &Connection, id: &str) -> Result<bool, String> {
|
|
let deleted = conn
|
|
.execute(crate::dbq!(db_contacts::Q_002), params![id])
|
|
.map_err(|e| format!("Failed to delete contact: {e}"))?;
|
|
Ok(deleted > 0)
|
|
}
|
|
|
|
pub fn search_contacts(conn: &Connection, query: &str, limit: u32) -> Result<Vec<Contact>, String> {
|
|
let escaped = escape_like(query);
|
|
let pattern = format!("%{escaped}%");
|
|
|
|
let sql =
|
|
crate::dbq!(db_contacts::FORMAT_005).replace("{CONTACT_SELECT_COLS}", CONTACT_SELECT_COLS);
|
|
|
|
let mut stmt = conn
|
|
.prepare(&sql)
|
|
.map_err(|e| format!("Failed to prepare search_contacts: {e}"))?;
|
|
let rows = stmt
|
|
.query_map(params![pattern, limit], row_to_contact)
|
|
.map_err(|e| format!("Failed to query search contacts: {e}"))?;
|
|
|
|
let mut contacts = Vec::new();
|
|
for row in rows {
|
|
contacts.push(row.map_err(|e| format!("Failed to read contact row: {e}"))?);
|
|
}
|
|
Ok(contacts)
|
|
}
|
|
|
|
pub fn search_known_addresses(
|
|
conn: &Connection,
|
|
query: &str,
|
|
limit: u32,
|
|
) -> Result<Vec<KnownAddress>, String> {
|
|
let escaped = escape_like(query);
|
|
let pattern = format!("%{escaped}%");
|
|
|
|
let sql = crate::dbq!(db_contacts::Q_003);
|
|
|
|
let mut stmt = conn
|
|
.prepare(sql)
|
|
.map_err(|e| format!("Failed to prepare search_known_addresses: {e}"))?;
|
|
|
|
let rows = stmt
|
|
.query_map(params![pattern, limit], |row| {
|
|
Ok(KnownAddress {
|
|
email: row.get(0)?,
|
|
name: row.get(1)?,
|
|
})
|
|
})
|
|
.map_err(|e| format!("Failed to query known addresses: {e}"))?;
|
|
|
|
let mut addresses = Vec::new();
|
|
for row in rows {
|
|
addresses.push(row.map_err(|e| format!("Failed to read known address row: {e}"))?);
|
|
}
|
|
Ok(addresses)
|
|
}
|
|
|
|
pub fn populate_known_addresses(
|
|
conn: &Connection,
|
|
from_address: &str,
|
|
from_name: &str,
|
|
to_json: &str,
|
|
cc_json: &str,
|
|
) -> Result<(), String> {
|
|
if !from_address.is_empty() {
|
|
conn.execute(
|
|
crate::dbq!(db_contacts::Q_004),
|
|
params![from_address, from_name],
|
|
)
|
|
.map_err(|e| format!("Failed to insert known address (from): {e}"))?;
|
|
}
|
|
|
|
insert_addresses_from_json(conn, to_json)?;
|
|
|
|
insert_addresses_from_json(conn, cc_json)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn insert_addresses_from_json(conn: &Connection, json: &str) -> Result<(), String> {
|
|
let entries: Vec<serde_json::Value> = match serde_json::from_str(json) {
|
|
Ok(v) => v,
|
|
Err(_) => return Ok(()),
|
|
};
|
|
|
|
for entry in &entries {
|
|
let email = entry.get("address").and_then(|v| v.as_str()).unwrap_or("");
|
|
if email.is_empty() {
|
|
continue;
|
|
}
|
|
let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
|
conn.execute(crate::dbq!(db_contacts::Q_005), params![email, name])
|
|
.map_err(|e| format!("Failed to insert known address (json): {e}"))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn increment_contact_count(conn: &Connection, email: &str) -> Result<(), String> {
|
|
conn.execute(crate::dbq!(db_contacts::Q_006), params![email])
|
|
.map_err(|e| format!("Failed to increment contact count: {e}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn auto_add_contact(conn: &Connection, email: &str, name: &str) -> Result<(), String> {
|
|
let id = Uuid::new_v4().to_string();
|
|
conn.execute(crate::dbq!(db_contacts::Q_007), params![id, email, name])
|
|
.map_err(|e| format!("Failed to auto-add contact: {e}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn set_contact_photo(
|
|
conn: &Connection,
|
|
contact_id: &str,
|
|
content_type: &str,
|
|
data: &[u8],
|
|
) -> Result<(), String> {
|
|
conn.execute(
|
|
crate::dbq!(db_contacts::Q_008),
|
|
params![contact_id, content_type, data],
|
|
)
|
|
.map_err(|e| format!("Failed to save contact photo: {e}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn get_contact_photo(
|
|
conn: &Connection,
|
|
contact_id: &str,
|
|
) -> Result<Option<(String, Vec<u8>)>, String> {
|
|
match conn.query_row(
|
|
crate::dbq!(db_contacts::Q_009),
|
|
params![contact_id],
|
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
) {
|
|
Ok(value) => Ok(Some(value)),
|
|
Err(crate::db::pool::DbError::QueryReturnedNoRows) => Ok(None),
|
|
Err(e) => Err(format!("Failed to load contact photo: {e}")),
|
|
}
|
|
}
|
|
|
|
pub fn delete_contact_photo(conn: &Connection, contact_id: &str) -> Result<bool, String> {
|
|
conn.execute(crate::dbq!(db_contacts::Q_010), params![contact_id])
|
|
.map(|n| n > 0)
|
|
.map_err(|e| format!("Failed to delete contact photo: {e}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db::pool::open_test_db;
|
|
|
|
fn sample_contact(id: &str, email: &str, name: &str) -> Contact {
|
|
Contact {
|
|
id: id.to_string(),
|
|
email: email.to_string(),
|
|
name: name.to_string(),
|
|
company: String::new(),
|
|
notes: String::new(),
|
|
phone: String::new(),
|
|
job_title: String::new(),
|
|
address: String::new(),
|
|
website: String::new(),
|
|
birthday: String::new(),
|
|
emails: Vec::new(),
|
|
phones: Vec::new(),
|
|
addresses: Vec::new(),
|
|
is_favorite: false,
|
|
last_contacted: None,
|
|
contact_count: 0,
|
|
source: "manual".to_string(),
|
|
created_at: "2024-01-01 00:00:00".to_string(),
|
|
updated_at: "2024-01-01 00:00:00".to_string(),
|
|
has_photo: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_upsert_and_get_contact() {
|
|
let conn = open_test_db();
|
|
let contact = sample_contact("c1", "alice@example.com", "Alice");
|
|
|
|
upsert_contact(&conn, &contact).unwrap();
|
|
|
|
let fetched = get_contact(&conn, "c1").unwrap();
|
|
assert!(fetched.is_some());
|
|
let fetched = fetched.unwrap();
|
|
assert_eq!(fetched.id, "c1");
|
|
assert_eq!(fetched.email, "alice@example.com");
|
|
assert_eq!(fetched.name, "Alice");
|
|
assert_eq!(fetched.source, "manual");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_contact_by_email() {
|
|
let conn = open_test_db();
|
|
let contact = sample_contact("c2", "bob@example.com", "Bob");
|
|
upsert_contact(&conn, &contact).unwrap();
|
|
|
|
let fetched = get_contact_by_email(&conn, "bob@example.com").unwrap();
|
|
assert!(fetched.is_some());
|
|
let fetched = fetched.unwrap();
|
|
assert_eq!(fetched.id, "c2");
|
|
assert_eq!(fetched.name, "Bob");
|
|
|
|
let missing = get_contact_by_email(&conn, "nobody@example.com").unwrap();
|
|
assert!(missing.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_contacts() {
|
|
let conn = open_test_db();
|
|
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
|
|
upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob")).unwrap();
|
|
|
|
let all = list_contacts(&conn, None, 100, 0).unwrap();
|
|
assert_eq!(all.len(), 2);
|
|
|
|
assert_eq!(all[0].name, "Alice");
|
|
assert_eq!(all[1].name, "Bob");
|
|
}
|
|
|
|
#[test]
|
|
fn test_search_contacts() {
|
|
let conn = open_test_db();
|
|
upsert_contact(
|
|
&conn,
|
|
&sample_contact("c1", "alice@example.com", "Alice Smith"),
|
|
)
|
|
.unwrap();
|
|
upsert_contact(&conn, &sample_contact("c2", "bob@example.com", "Bob Jones")).unwrap();
|
|
|
|
let results = search_contacts(&conn, "Alice", 10).unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].name, "Alice Smith");
|
|
|
|
let results = search_contacts(&conn, "bob@", 10).unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].name, "Bob Jones");
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_contact() {
|
|
let conn = open_test_db();
|
|
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
|
|
|
|
let deleted = delete_contact(&conn, "c1").unwrap();
|
|
assert!(deleted);
|
|
|
|
let fetched = get_contact(&conn, "c1").unwrap();
|
|
assert!(fetched.is_none());
|
|
|
|
let deleted_again = delete_contact(&conn, "c1").unwrap();
|
|
assert!(!deleted_again);
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_add_contact() {
|
|
let conn = open_test_db();
|
|
|
|
auto_add_contact(&conn, "auto@example.com", "Auto User").unwrap();
|
|
|
|
let fetched = get_contact_by_email(&conn, "auto@example.com").unwrap();
|
|
assert!(fetched.is_some());
|
|
let fetched = fetched.unwrap();
|
|
assert_eq!(fetched.source, "auto");
|
|
assert_eq!(fetched.name, "Auto User");
|
|
|
|
auto_add_contact(&conn, "auto@example.com", "Different Name").unwrap();
|
|
let fetched2 = get_contact_by_email(&conn, "auto@example.com")
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(fetched2.name, "Auto User");
|
|
}
|
|
|
|
#[test]
|
|
fn test_increment_contact_count() {
|
|
let conn = open_test_db();
|
|
upsert_contact(&conn, &sample_contact("c1", "alice@example.com", "Alice")).unwrap();
|
|
|
|
increment_contact_count(&conn, "alice@example.com").unwrap();
|
|
increment_contact_count(&conn, "alice@example.com").unwrap();
|
|
|
|
let fetched = get_contact_by_email(&conn, "alice@example.com")
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(fetched.contact_count, 2);
|
|
assert!(fetched.last_contacted.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_search_known_addresses_basic() {
|
|
let conn = open_test_db();
|
|
|
|
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
|
|
populate_known_addresses(
|
|
&conn,
|
|
"sender@other.com",
|
|
"Sender",
|
|
r#"[{"name":"Bob To","address":"bob@test.com"}]"#,
|
|
"[]",
|
|
)
|
|
.unwrap();
|
|
populate_known_addresses(
|
|
&conn,
|
|
"sender@other.com",
|
|
"Sender",
|
|
"[]",
|
|
r#"[{"name":"Charlie Cc","address":"charlie@test.com"}]"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
|
|
assert_eq!(results.len(), 3);
|
|
|
|
assert_eq!(results[0].email, "alice@test.com");
|
|
assert_eq!(results[0].name, "Alice From");
|
|
assert_eq!(results[1].email, "bob@test.com");
|
|
assert_eq!(results[1].name, "Bob To");
|
|
assert_eq!(results[2].email, "charlie@test.com");
|
|
assert_eq!(results[2].name, "Charlie Cc");
|
|
}
|
|
|
|
#[test]
|
|
fn test_search_known_addresses_excludes_contacts() {
|
|
let conn = open_test_db();
|
|
|
|
upsert_contact(
|
|
&conn,
|
|
&sample_contact("c1", "alice@test.com", "Alice Contact"),
|
|
)
|
|
.unwrap();
|
|
|
|
populate_known_addresses(&conn, "alice@test.com", "Alice From", "[]", "[]").unwrap();
|
|
populate_known_addresses(
|
|
&conn,
|
|
"bob@test.com",
|
|
"Bob From",
|
|
r#"[{"name":"Alice To","address":"alice@test.com"}]"#,
|
|
"[]",
|
|
)
|
|
.unwrap();
|
|
populate_known_addresses(
|
|
&conn,
|
|
"charlie@test.com",
|
|
"Charlie",
|
|
"[]",
|
|
r#"[{"name":"Alice Cc","address":"alice@test.com"}]"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
|
|
|
|
assert_eq!(results.len(), 2);
|
|
let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect();
|
|
assert!(emails.contains(&"bob@test.com"));
|
|
assert!(emails.contains(&"charlie@test.com"));
|
|
assert!(!emails.contains(&"alice@test.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_search_known_addresses_handles_invalid_json() {
|
|
let conn = open_test_db();
|
|
|
|
populate_known_addresses(&conn, "valid@test.com", "Valid User", "[]", "[]").unwrap();
|
|
populate_known_addresses(
|
|
&conn,
|
|
"sender@other.com",
|
|
"Sender",
|
|
r#"[{"name":"","address":"emptyname@test.com"}]"#,
|
|
r#"[{"name":"Cc Person","address":"ccperson@test.com"}]"#,
|
|
)
|
|
.unwrap();
|
|
populate_known_addresses(
|
|
&conn,
|
|
"sender@other.com",
|
|
"Sender",
|
|
r#"[{"name":"Json Person","address":"json@test.com"}]"#,
|
|
"[]",
|
|
)
|
|
.unwrap();
|
|
|
|
let results = search_known_addresses(&conn, "test.com", 10).unwrap();
|
|
|
|
assert_eq!(results.len(), 4);
|
|
let emails: Vec<&str> = results.iter().map(|a| a.email.as_str()).collect();
|
|
assert!(emails.contains(&"valid@test.com"));
|
|
assert!(emails.contains(&"emptyname@test.com"));
|
|
assert!(emails.contains(&"ccperson@test.com"));
|
|
assert!(emails.contains(&"json@test.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_populate_known_addresses_deduplicates() {
|
|
let conn = open_test_db();
|
|
|
|
populate_known_addresses(&conn, "alice@test.com", "Alice", "[]", "[]").unwrap();
|
|
|
|
populate_known_addresses(&conn, "alice@test.com", "Alice Different", "[]", "[]").unwrap();
|
|
|
|
let results = search_known_addresses(&conn, "alice@test.com", 10).unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].name, "Alice Different");
|
|
}
|
|
|
|
#[test]
|
|
fn test_populate_known_addresses_skips_invalid_json() {
|
|
let conn = open_test_db();
|
|
|
|
populate_known_addresses(&conn, "sender@test.com", "Sender", "not valid json", "[]")
|
|
.unwrap();
|
|
|
|
let results = search_known_addresses(&conn, "sender@test.com", 10).unwrap();
|
|
assert_eq!(results.len(), 1);
|
|
assert_eq!(results[0].email, "sender@test.com");
|
|
}
|
|
}
|