···
+
// this entire file could probably be replaced with an actual orm or
+
// something but im too lazy to find one thats good enough
+
use crate::utils::calculate_image_hash;
+
use std::collections::HashMap;
+
use anyhow::{Context, Result};
+
use rusqlite::{Connection, OptionalExtension, Result as SqlResult, params};
+
use std::collections::HashSet;
+
use std::path::{Path, PathBuf};
+
use std::process::{Command, Stdio};
+
use std::time::{SystemTime, UNIX_EPOCH};
+
pub fn setup_database(path: &Path) -> Result<Connection> {
+
let conn = Connection::open(path)
+
.with_context(|| format!("Failed to open or create database: {}", path.display()))?;
+
conn.execute("PRAGMA foreign_keys = ON;", [])?;
+
"CREATE TABLE IF NOT EXISTS images (
+
id TEXT PRIMARY KEY NOT NULL,
+
path TEXT NOT NULL UNIQUE,
+
last_used INTEGER NOT NULL DEFAULT 0,
+
used_count INTEGER NOT NULL DEFAULT 0,
+
favorite BOOLEAN NOT NULL DEFAULT 0 CHECK (favorite IN (0, 1))
+
"CREATE TABLE IF NOT EXISTS tags (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
name TEXT NOT NULL UNIQUE COLLATE NOCASE
+
"CREATE TABLE IF NOT EXISTS image_tags (
+
image_id TEXT NOT NULL,
+
tag_id INTEGER NOT NULL,
+
PRIMARY KEY (image_id, tag_id),
+
FOREIGN KEY (image_id) REFERENCES images (id) ON DELETE CASCADE,
+
FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE
+
pub fn create_indices(conn: &Connection) -> Result<()> {
+
"CREATE INDEX IF NOT EXISTS idx_images_path ON images(path);",
+
"CREATE INDEX IF NOT EXISTS idx_images_favorite ON images(favorite);",
+
"CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name);",
+
"CREATE INDEX IF NOT EXISTS idx_image_tags_tag_id ON image_tags(tag_id);",
+
pub fn get_image_id(conn: &Connection, path: &Path) -> SqlResult<Option<String>> {
+
"SELECT id FROM images WHERE path = ?",
+
params![path.to_string_lossy()],
+
pub fn get_or_create_tag_id(conn: &Connection, tag: &str) -> SqlResult<i64> {
+
conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", params![tag])?;
+
"SELECT id FROM tags WHERE name = ? COLLATE NOCASE",
+
pub fn get_or_insert_image_id(conn: &Connection, path: &Path) -> Result<String> {
+
let path_str = path.to_string_lossy();
+
if let Some(id) = get_image_id(conn, path)? {
+
"Image not found in DB, calculating hash and adding: {}",
+
let hash_id = calculate_image_hash(path).with_context(|| {
+
format!("Failed to calculate hash for new image: {}", path.display())
+
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
+
"INSERT INTO images (id, path, last_used, used_count, favorite) VALUES (?, ?, ?, 0, 0)",
+
params![hash_id, path_str, now],
+
"Failed to insert new image into database: {}",
+
pub fn add_tag(conn: &Connection, path: &Path, tag: &str) -> Result<()> {
+
let image_id = get_or_insert_image_id(conn, path)?;
+
let tag_id = get_or_create_tag_id(conn, tag)?;
+
let changed = conn.execute(
+
"INSERT OR IGNORE INTO image_tags (image_id, tag_id) VALUES (?, ?)",
+
params![image_id, tag_id],
+
println!("Added tag '{}' to image: {}", tag, path.display());
+
"Tag '{}' already present for image: {}",
+
pub fn remove_tag(conn: &Connection, path: &Path, tag: &str) -> Result<()> {
+
let image_id_opt = get_image_id(conn, path)?;
+
if let Some(image_id) = image_id_opt {
+
let tag_id_opt: SqlResult<Option<i64>> = conn
+
"SELECT id FROM tags WHERE name = ? COLLATE NOCASE",
+
let changed = conn.execute(
+
"DELETE FROM image_tags WHERE image_id = ? AND tag_id = ?",
+
params![image_id, tag_id],
+
println!("Removed tag '{}' from image: {}", tag, path.display());
+
println!("Tag '{}' was not found on image: {}", tag, path.display());
+
println!("Tag '{}' not found in database.", tag);
+
return Err(e).context("Database error checking for tag ID");
+
println!("Image not found in database: {}", path.display());
+
pub fn list_tags(conn: &Connection, path: &Path) -> Result<()> {
+
let image_id_opt = get_image_id(conn, path)?;
+
if let Some(image_id) = image_id_opt {
+
let mut stmt = conn.prepare(
+
"SELECT t.name FROM tags t
+
JOIN image_tags it ON t.id = it.tag_id
+
ORDER BY t.name COLLATE NOCASE",
+
let tags: Vec<String> = stmt
+
.query_map(params![image_id], |row| row.get(0))?
+
.collect::<SqlResult<_>>()?;
+
let favorite: bool = conn.query_row(
+
"SELECT favorite FROM images WHERE id = ?",
+
println!("Details for image: {}", path.display());
+
println!(" Tags: None");
+
println!(" - {}", tag);
+
println!("Image not found in database: {}", path.display());
+
pub fn add_favorite(conn: &Connection, path: &Path) -> Result<()> {
+
let image_id = get_or_insert_image_id(conn, path)?;
+
let changed = conn.execute(
+
"UPDATE images SET favorite = 1 WHERE id = ?",
+
println!("Marked image as favorite: {}", path.display());
+
"Image already marked as favorite or not found for update: {}",
+
pub fn remove_favorite(conn: &Connection, path: &Path) -> Result<()> {
+
let image_id_opt = get_image_id(conn, path)?;
+
if let Some(image_id) = image_id_opt {
+
let changed = conn.execute(
+
"UPDATE images SET favorite = 0 WHERE id = ?",
+
println!("Removed image from favorites: {}", path.display());
+
"Image was not marked as favorite or not found for update: {}",
+
println!("Image not found in database: {}", path.display());
+
pub fn list_images(conn: &Connection, tag: Option<&str>, favorite_only: bool) -> Result<()> {
+
let mut query_parts = vec!["SELECT i.path, i.used_count, i.favorite FROM images i"];
+
let mut conditions = vec![];
+
if let Some(_t) = tag {
+
query_parts.push("JOIN image_tags it ON i.id = it.image_id");
+
query_parts.push("JOIN tags t ON it.tag_id = t.id");
+
conditions.push("t.name = ? COLLATE NOCASE");
+
conditions.push("i.favorite = 1");
+
if !conditions.is_empty() {
+
query_parts.push("WHERE");
+
conditions_str = conditions.join(" AND ");
+
query_parts.push(&conditions_str);
+
query_parts.push("ORDER BY i.path COLLATE NOCASE");
+
let final_query = query_parts.join(" ");
+
let mut stmt = conn.prepare(&final_query)?;
+
let row_mapper = |row: &rusqlite::Row| {
+
row.get::<_, String>(0)?,
+
row.get::<_, bool>(2)?,
+
let images_result = if let Some(t) = tag {
+
stmt.query_map(params![t], row_mapper)
+
stmt.query_map([], row_mapper)
+
let images: Vec<(String, u32, bool)> = images_result?
+
.collect::<SqlResult<Vec<_>>>()
+
.context("Failed to retrieve image list from database")?;
+
let filter_desc = match (tag, favorite_only) {
+
(Some(t), true) => format!("favorite images with tag '{}'", t),
+
(Some(t), false) => format!("images with tag '{}'", t),
+
(None, true) => "favorite images".to_string(),
+
(None, false) => "all images".to_string(),
+
println!("No {} found in the database.", filter_desc);
+
let filter_desc = match (tag, favorite_only) {
+
(Some(t), true) => format!("Favorite images with tag '{}'", t),
+
(Some(t), false) => format!("Images with tag '{}'", t),
+
(None, true) => "Favorite images".to_string(),
+
(None, false) => "All images".to_string(),
+
println!("Listing {}:", filter_desc);
+
println!("{:<2} {:<6} {}", "★", "Uses", "Path");
+
println!("{:-<2} {:-<6} {:-<10}", "", "", "");
+
for (path, uses, favorite) in &images {
+
let star = if *favorite { "★" } else { " " };
+
println!("{:<2} {:<6} {}", star, uses, path);
+
println!("\nTotal: {} images listed.", images.len());
+
pub fn get_current_wallpaper(conn: &Connection) -> Result<String> {
+
"CREATE TABLE IF NOT EXISTS settings (
+
key TEXT PRIMARY KEY NOT NULL,
+
"SELECT value FROM settings WHERE key = 'current_wallpaper'",
+
|row| row.get::<_, String>(0),
+
let path_buf = PathBuf::from(&path);
+
anyhow::bail!("Current wallpaper file no longer exists: {}", path)
+
Err(rusqlite::Error::QueryReturnedNoRows) => {
+
"Current wallpaper not set. Please use 'erm random' first or specify a path."
+
anyhow::bail!("Error retrieving current wallpaper: {}", e)
+
pub fn refresh_database(conn: &mut Connection, config: &Config) -> Result<()> {
+
println!("Starting database refresh...");
+
let start_time = std::time::Instant::now();
+
let tx = conn.transaction()?;
+
let mut db_images_stmt = tx.prepare("SELECT id, path FROM images")?;
+
let db_images: HashMap<String, String> = db_images_stmt
+
.query_map([], |row| Ok((row.get(1)?, row.get(0)?)))?
+
.collect::<SqlResult<HashMap<String, String>>>()?;
+
let mut current_fs_paths: HashSet<PathBuf> = HashSet::new();
+
let mut discovered_count = 0;
+
let extensions = config
+
.map(|s| format!(".{}", s.to_lowercase()))
+
.collect::<HashSet<_>>()
+
[".jpg", ".jpeg", ".png", ".webp"]
+
.map(|&s| s.to_string())
+
let exclude_patterns = config
+
.filter_map(|p| glob::Pattern::new(p).ok())
+
for dir_path_str in &config.directories {
+
let expanded_path_str = shellexpand::tilde(dir_path_str).into_owned();
+
let start_path = Path::new(&expanded_path_str);
+
if !start_path.is_dir() {
+
"Warning: Configured directory does not exist or is not a directory: {}",
+
println!("Scanning directory: {}", start_path.display());
+
let include_subdirs = config.include_subdirectories.unwrap_or(true);
+
let mut walker = WalkDir::new(start_path).follow_links(true);
+
walker = walker.max_depth(1);
+
for entry_result in walker.into_iter() {
+
let entry = match entry_result {
+
eprintln!("Warning: Error traversing directory: {}", e);
+
if !entry.file_type().is_file() {
+
let file_path = entry.path();
+
if let Some(ext) = file_path.extension().and_then(|e| e.to_str()) {
+
if !extensions.contains(&format!(".{}", ext.to_lowercase())) {
+
let canonical_path = match fs::canonicalize(file_path) {
+
"Warning: Could not canonicalize path {}: {}",
+
let canonical_path_str = canonical_path.to_string_lossy();
+
let should_exclude = exclude_patterns
+
.any(|pattern| pattern.matches(&canonical_path_str));
+
current_fs_paths.insert(canonical_path);
+
"Discovered {} potential image files on disk.",
+
let db_paths: HashSet<String> = db_images.keys().cloned().collect();
+
let fs_paths_str: HashSet<String> = current_fs_paths
+
.map(|p| p.to_string_lossy().into_owned())
+
let paths_to_remove = db_paths
+
.difference(&fs_paths_str)
+
let mut removed_count = 0;
+
if !paths_to_remove.is_empty() {
+
"Removing {} entries from database that no longer exist on disk...",
+
let mut delete_stmt = tx.prepare("DELETE FROM images WHERE path = ?")?;
+
for path_str in paths_to_remove {
+
match delete_stmt.execute(params![path_str]) {
+
Ok(count) => removed_count += count,
+
Err(e) => eprintln!("Error deleting image with path {}: {}", path_str, e),
+
let paths_to_add = fs_paths_str.difference(&db_paths).collect::<Vec<_>>();
+
let mut added_count = 0;
+
let mut hash_errors = 0;
+
if !paths_to_add.is_empty() {
+
"Adding {} new images to the database...",
+
let mut insert_stmt = tx.prepare(
+
"INSERT OR IGNORE INTO images (id, path, last_used, used_count, favorite) VALUES (?, ?, 0, 0, 0)",
+
for path_str in paths_to_add {
+
print!("added {} to database\r", added_count);
+
let path = PathBuf::from(path_str);
+
match calculate_image_hash(&path) {
+
match insert_stmt.execute(params![hash_id, path_str]) {
+
Ok(1) => added_count += 1,
+
Ok(0) => { /* id or path already existed*/ }
+
Ok(_) => { unreachable!() }
+
Err(e) => eprintln!("error inserting image with path {}: {}", path_str, e),
+
"error calculating hash for {}: {}. Skipping file.",
+
let duration = start_time.elapsed();
+
"database refresh completed in {:.2?}. Added: {}, Removed: {}{}",
+
format!(", hash errors: {}", hash_errors)
+
pub fn select_wallpaper(
+
) -> Result<Option<String>> {
+
let mut query_parts = vec!["SELECT i.path FROM images i"];
+
let mut conditions = vec![];
+
if let Some(_t) = tag {
+
query_parts.push("JOIN image_tags it ON i.id = it.image_id");
+
query_parts.push("JOIN tags t ON it.tag_id = t.id");
+
conditions.push("t.name = ? COLLATE NOCASE");
+
conditions.push("i.favorite = 1");
+
let conditions_str = conditions.join(" AND ");
+
query_parts.push(&conditions_str);
+
query_parts.push("ORDER BY i.used_count ASC, RANDOM()");
+
query_parts.push("LIMIT 50");
+
let final_query = query_parts.join(" ");
+
let mut stmt = conn.prepare(&final_query)?;
+
let candidate_paths: Vec<String> = if let Some(t) = tag {
+
stmt.query_map(params![t], |row| row.get(0))?
+
.collect::<SqlResult<Vec<String>>>()?
+
stmt.query_map([], |row| row.get(0))?
+
.collect::<SqlResult<Vec<String>>>()?
+
if candidate_paths.is_empty() {
+
let mut rng = rand::rng();
+
Ok(candidate_paths.choose(&mut rng).cloned())
+
pub fn update_usage(conn: &Connection, path: &str) -> Result<()> {
+
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
+
let updated_rows = conn.execute(
+
"UPDATE images SET last_used = ?, used_count = used_count + 1 WHERE path = ?",
+
"Warning: Tried to update usage for path not found in DB: {}",
+
pub fn set_wallpaper(conn: &Connection, path_str: &str, config: &Config) -> Result<()> {
+
let path = Path::new(path_str);
+
anyhow::bail!("Wallpaper file does not exist: {}", path_str);
+
let pgrep_output = Command::new("pgrep").arg("-x").arg("swaybg").output();
+
if let Ok(output) = pgrep_output {
+
if output.status.success() {
+
println!("Found existing swaybg process, terminating it...");
+
.context("Failed to terminate existing swaybg process")?;
+
std::thread::sleep(std::time::Duration::from_millis(100));
+
let mut cmd = Command::new("swaybg");
+
cmd.arg("-i").arg(path);
+
if let Some(args) = &config.swaybg_args {
+
println!("Setting wallpaper: {}", path.display());
+
.context("Failed to spawn swaybg in daemon mode")?;
+
println!("Started swaybg daemon (PID: {})", child.id());
+
std::mem::forget(child);
+
"CREATE TABLE IF NOT EXISTS settings (
+
key TEXT PRIMARY KEY NOT NULL,
+
"INSERT OR REPLACE INTO settings (key, value) VALUES ('current_wallpaper', ?)",