Initial commit — Tauri image gallery with sqlite-vec
Sets up Tauri v2 + React frontend with SQLite metadata store, sqlite-vec for CLIP embedding infrastructure, and r2d2 connection pool replacing the single Arc<Mutex<Connection>>. Indexer now uses rayon for parallel file metadata collection.
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
use crate::vector;
|
||||
use anyhow::Result;
|
||||
use r2d2::Pool;
|
||||
use r2d2_sqlite::SqliteConnectionManager;
|
||||
use rusqlite::{params, Connection, Row};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
pub type DbPool = Pool<SqliteConnectionManager>;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ConnectionOptions;
|
||||
|
||||
impl r2d2::CustomizeConnection<Connection, rusqlite::Error> for ConnectionOptions {
|
||||
fn on_acquire(&self, conn: &mut Connection) -> Result<(), rusqlite::Error> {
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA busy_timeout=5000;",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Folder {
|
||||
pub id: i64,
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub image_count: i64,
|
||||
pub indexed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageRecord {
|
||||
pub id: i64,
|
||||
pub folder_id: i64,
|
||||
pub path: String,
|
||||
pub filename: String,
|
||||
pub thumbnail_path: Option<String>,
|
||||
pub width: Option<i64>,
|
||||
pub height: Option<i64>,
|
||||
pub file_size: i64,
|
||||
pub created_at: Option<String>,
|
||||
pub modified_at: Option<String>,
|
||||
pub mime_type: String,
|
||||
pub media_kind: String,
|
||||
pub favorite: bool,
|
||||
pub rating: i64,
|
||||
pub embedding_status: String,
|
||||
pub embedding_model: Option<String>,
|
||||
pub embedding_updated_at: Option<String>,
|
||||
pub embedding_error: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EmbeddingJob {
|
||||
pub image_id: i64,
|
||||
pub status: String,
|
||||
pub attempts: i64,
|
||||
pub last_error: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
pub fn create_pool(db_path: &Path) -> Result<DbPool> {
|
||||
vector::register_sqlite_vec();
|
||||
let manager = SqliteConnectionManager::file(db_path);
|
||||
let pool = Pool::builder()
|
||||
.connection_customizer(Box::new(ConnectionOptions))
|
||||
.build(manager)?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub fn migrate(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
image_count INTEGER NOT NULL DEFAULT 0,
|
||||
indexed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
filename TEXT NOT NULL,
|
||||
thumbnail_path TEXT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
modified_at TEXT,
|
||||
mime_type TEXT NOT NULL DEFAULT 'image/jpeg',
|
||||
media_kind TEXT NOT NULL DEFAULT 'image',
|
||||
favorite INTEGER NOT NULL DEFAULT 0,
|
||||
rating INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS embedding_jobs (
|
||||
image_id INTEGER PRIMARY KEY REFERENCES images(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_images_folder_id ON images(folder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_modified_at ON images(modified_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_embedding_jobs_status ON embedding_jobs(status);
|
||||
",
|
||||
)?;
|
||||
|
||||
ensure_column(
|
||||
conn,
|
||||
"images",
|
||||
"embedding_status",
|
||||
"TEXT NOT NULL DEFAULT 'pending'",
|
||||
)?;
|
||||
ensure_column(conn, "images", "embedding_model", "TEXT")?;
|
||||
ensure_column(conn, "images", "embedding_updated_at", "TEXT")?;
|
||||
ensure_column(conn, "images", "embedding_error", "TEXT")?;
|
||||
ensure_column(
|
||||
conn,
|
||||
"images",
|
||||
"media_kind",
|
||||
"TEXT NOT NULL DEFAULT 'image'",
|
||||
)?;
|
||||
ensure_column(conn, "images", "favorite", "INTEGER NOT NULL DEFAULT 0")?;
|
||||
ensure_column(conn, "images", "rating", "INTEGER NOT NULL DEFAULT 0")?;
|
||||
|
||||
vector::migrate(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn insert_folder(conn: &Connection, path: &str, name: &str) -> Result<i64> {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO folders (path, name) VALUES (?1, ?2)",
|
||||
params![path, name],
|
||||
)?;
|
||||
let id: i64 = conn.query_row(
|
||||
"SELECT id FROM folders WHERE path = ?1",
|
||||
params![path],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn upsert_image(conn: &Connection, img: &ImageRecord) -> Result<i64> {
|
||||
let id = conn.query_row(
|
||||
"INSERT INTO images (folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type, media_kind, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
folder_id = excluded.folder_id,
|
||||
filename = excluded.filename,
|
||||
thumbnail_path = excluded.thumbnail_path,
|
||||
width = excluded.width,
|
||||
height = excluded.height,
|
||||
file_size = excluded.file_size,
|
||||
created_at = excluded.created_at,
|
||||
modified_at = excluded.modified_at,
|
||||
mime_type = excluded.mime_type,
|
||||
media_kind = excluded.media_kind,
|
||||
embedding_status = excluded.embedding_status,
|
||||
embedding_model = excluded.embedding_model,
|
||||
embedding_updated_at = excluded.embedding_updated_at,
|
||||
embedding_error = excluded.embedding_error
|
||||
RETURNING id",
|
||||
params![
|
||||
img.folder_id,
|
||||
img.path,
|
||||
img.filename,
|
||||
img.thumbnail_path,
|
||||
img.width,
|
||||
img.height,
|
||||
img.file_size,
|
||||
img.created_at,
|
||||
img.modified_at,
|
||||
img.mime_type,
|
||||
img.media_kind,
|
||||
img.favorite,
|
||||
img.rating,
|
||||
img.embedding_status,
|
||||
img.embedding_model,
|
||||
img.embedding_updated_at,
|
||||
img.embedding_error,
|
||||
],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn enqueue_embedding_job(conn: &Connection, image_id: i64) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO embedding_jobs (image_id, status, attempts, last_error, created_at, updated_at)
|
||||
VALUES (?1, 'pending', 0, NULL, datetime('now'), datetime('now'))
|
||||
ON CONFLICT(image_id) DO UPDATE SET
|
||||
status = 'pending',
|
||||
last_error = NULL,
|
||||
updated_at = datetime('now')",
|
||||
[image_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_next_embedding_job(conn: &Connection) -> Result<Option<EmbeddingJob>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT image_id, status, attempts, last_error, created_at, updated_at
|
||||
FROM embedding_jobs
|
||||
WHERE status = 'pending'
|
||||
ORDER BY updated_at, image_id
|
||||
LIMIT 1",
|
||||
)?;
|
||||
|
||||
let mut rows = stmt.query([])?;
|
||||
let Some(row) = rows.next()? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(EmbeddingJob {
|
||||
image_id: row.get(0)?,
|
||||
status: row.get(1)?,
|
||||
attempts: row.get(2)?,
|
||||
last_error: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
}))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn mark_embedding_job_processing(conn: &Connection, image_id: i64) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE embedding_jobs
|
||||
SET status = 'processing', attempts = attempts + 1, updated_at = datetime('now')
|
||||
WHERE image_id = ?1",
|
||||
[image_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn mark_embedding_ready(conn: &Connection, image_id: i64, model: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE images
|
||||
SET embedding_status = 'ready', embedding_model = ?2, embedding_updated_at = datetime('now'), embedding_error = NULL
|
||||
WHERE id = ?1",
|
||||
params![image_id, model],
|
||||
)?;
|
||||
conn.execute("DELETE FROM embedding_jobs WHERE image_id = ?1", [image_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn mark_embedding_failed(conn: &Connection, image_id: i64, error: &str) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE images
|
||||
SET embedding_status = 'failed', embedding_error = ?2
|
||||
WHERE id = ?1",
|
||||
params![image_id, error],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE embedding_jobs
|
||||
SET status = 'failed', last_error = ?2, updated_at = datetime('now')
|
||||
WHERE image_id = ?1",
|
||||
params![image_id, error],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_folder_count(conn: &Connection, folder_id: i64) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE folders SET image_count = (SELECT COUNT(*) FROM images WHERE folder_id = ?1), indexed_at = datetime('now') WHERE id = ?1",
|
||||
params![folder_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_image_details(
|
||||
conn: &Connection,
|
||||
image_id: i64,
|
||||
favorite: Option<bool>,
|
||||
rating: Option<i64>,
|
||||
) -> Result<ImageRecord> {
|
||||
conn.execute(
|
||||
"UPDATE images
|
||||
SET favorite = COALESCE(?2, favorite),
|
||||
rating = COALESCE(?3, rating)
|
||||
WHERE id = ?1",
|
||||
params![image_id, favorite, rating],
|
||||
)?;
|
||||
|
||||
conn.query_row(
|
||||
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type,
|
||||
media_kind, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
|
||||
FROM images
|
||||
WHERE id = ?1",
|
||||
[image_id],
|
||||
map_image_row,
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, path, name, image_count, indexed_at FROM folders ORDER BY name")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(Folder {
|
||||
id: row.get(0)?,
|
||||
path: row.get(1)?,
|
||||
name: row.get(2)?,
|
||||
image_count: row.get(3)?,
|
||||
indexed_at: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
pub fn get_images(
|
||||
conn: &Connection,
|
||||
folder_id: Option<i64>,
|
||||
search: Option<&str>,
|
||||
media_kind: Option<&str>,
|
||||
favorites_only: bool,
|
||||
sort: &str,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
) -> Result<Vec<ImageRecord>> {
|
||||
let order = match sort {
|
||||
"name_asc" => "filename ASC",
|
||||
"name_desc" => "filename DESC",
|
||||
"date_asc" => "modified_at ASC NULLS LAST",
|
||||
"date_desc" => "modified_at DESC NULLS LAST",
|
||||
"size_asc" => "file_size ASC",
|
||||
"size_desc" => "file_size DESC",
|
||||
_ => "modified_at DESC NULLS LAST",
|
||||
};
|
||||
|
||||
let search_pattern = search.map(|value| format!("%{}%", value));
|
||||
let favorites_flag = i64::from(favorites_only);
|
||||
let sql = format!(
|
||||
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, mime_type,
|
||||
media_kind, favorite, rating, embedding_status, embedding_model, embedding_updated_at, embedding_error
|
||||
FROM images
|
||||
WHERE (?1 IS NULL OR folder_id = ?1)
|
||||
AND (?2 IS NULL OR filename LIKE ?2)
|
||||
AND (?3 IS NULL OR media_kind = ?3)
|
||||
AND (?4 = 0 OR favorite = 1)
|
||||
ORDER BY {}
|
||||
LIMIT ?5 OFFSET ?6",
|
||||
order
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(
|
||||
params![
|
||||
folder_id,
|
||||
search_pattern,
|
||||
media_kind,
|
||||
favorites_flag,
|
||||
limit,
|
||||
offset
|
||||
],
|
||||
map_image_row,
|
||||
)?;
|
||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
pub fn count_images(
|
||||
conn: &Connection,
|
||||
folder_id: Option<i64>,
|
||||
search: Option<&str>,
|
||||
media_kind: Option<&str>,
|
||||
favorites_only: bool,
|
||||
) -> Result<i64> {
|
||||
let search_pattern = search.map(|value| format!("%{}%", value));
|
||||
|
||||
let favorites_flag = i64::from(favorites_only);
|
||||
let count = conn.query_row(
|
||||
"SELECT COUNT(*) FROM images
|
||||
WHERE (?1 IS NULL OR folder_id = ?1)
|
||||
AND (?2 IS NULL OR filename LIKE ?2)
|
||||
AND (?3 IS NULL OR media_kind = ?3)
|
||||
AND (?4 = 0 OR favorite = 1)",
|
||||
params![folder_id, search_pattern, media_kind, favorites_flag],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn delete_folder(conn: &Connection, folder_id: i64) -> Result<()> {
|
||||
conn.execute("DELETE FROM folders WHERE id = ?1", params![folder_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_image_row(row: &Row<'_>) -> rusqlite::Result<ImageRecord> {
|
||||
Ok(ImageRecord {
|
||||
id: row.get(0)?,
|
||||
folder_id: row.get(1)?,
|
||||
path: row.get(2)?,
|
||||
filename: row.get(3)?,
|
||||
thumbnail_path: row.get(4)?,
|
||||
width: row.get(5)?,
|
||||
height: row.get(6)?,
|
||||
file_size: row.get(7)?,
|
||||
created_at: row.get(8)?,
|
||||
modified_at: row.get(9)?,
|
||||
mime_type: row.get(10)?,
|
||||
media_kind: row.get(11)?,
|
||||
favorite: row.get::<_, i64>(12)? != 0,
|
||||
rating: row.get(13)?,
|
||||
embedding_status: row.get(14)?,
|
||||
embedding_model: row.get(15)?,
|
||||
embedding_updated_at: row.get(16)?,
|
||||
embedding_error: row.get(17)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_column(conn: &Connection, table: &str, column: &str, definition: &str) -> Result<()> {
|
||||
let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table))?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let existing_name: String = row.get(1)?;
|
||||
if existing_name == column {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
&format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, definition),
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user