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:
2026-04-05 15:59:48 +01:00
commit c5e9c83ac9
51 changed files with 10540 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
use crate::db::{self, DbPool, Folder, ImageRecord};
use crate::indexer;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::{AppHandle, State};
pub type DbState = DbPool;
#[derive(Serialize)]
pub struct ImagesPage {
pub images: Vec<ImageRecord>,
pub total: i64,
pub offset: i64,
pub limit: i64,
}
#[derive(Deserialize)]
pub struct GetImagesParams {
pub folder_id: Option<i64>,
pub search: Option<String>,
pub media_kind: Option<String>,
pub favorites_only: Option<bool>,
pub sort: Option<String>,
pub offset: Option<i64>,
pub limit: Option<i64>,
}
#[derive(Deserialize)]
pub struct UpdateImageDetailsParams {
pub image_id: i64,
pub favorite: Option<bool>,
pub rating: Option<i64>,
}
#[tauri::command]
pub async fn add_folder(
app: AppHandle,
db: State<'_, DbState>,
path: String,
cache_dir: String,
) -> Result<Folder, String> {
let folder_path = PathBuf::from(&path);
if !folder_path.exists() || !folder_path.is_dir() {
return Err("Path is not a valid directory".into());
}
let name = folder_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.clone());
let folder_id = {
let conn = db.get().map_err(|e| e.to_string())?;
db::insert_folder(&conn, &path, &name).map_err(|e| e.to_string())?
};
let folders = {
let conn = db.get().map_err(|e| e.to_string())?;
db::get_folders(&conn).map_err(|e| e.to_string())?
};
let folder = folders
.into_iter()
.find(|f| f.id == folder_id)
.ok_or("Folder not found after insert")?;
let cache_path = PathBuf::from(cache_dir);
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path, cache_path);
Ok(folder)
}
#[tauri::command]
pub async fn get_folders(db: State<'_, DbState>) -> Result<Vec<Folder>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::get_folders(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn remove_folder(db: State<'_, DbState>, folder_id: i64) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::delete_folder(&conn, folder_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_images(
db: State<'_, DbState>,
params: GetImagesParams,
) -> Result<ImagesPage, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let sort = params.sort.as_deref().unwrap_or("date_desc");
let offset = params.offset.unwrap_or(0);
let limit = params.limit.unwrap_or(100);
let search = params.search.as_deref();
let media_kind = params.media_kind.as_deref();
let favorites_only = params.favorites_only.unwrap_or(false);
let total = db::count_images(&conn, params.folder_id, search, media_kind, favorites_only)
.map_err(|e| e.to_string())?;
let images = db::get_images(
&conn,
params.folder_id,
search,
media_kind,
favorites_only,
sort,
offset,
limit,
)
.map_err(|e| e.to_string())?;
Ok(ImagesPage {
images,
total,
offset,
limit,
})
}
#[tauri::command]
pub async fn update_image_details(
db: State<'_, DbState>,
params: UpdateImageDetailsParams,
) -> Result<ImageRecord, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::update_image_details(&conn, params.image_id, params.favorite, params.rating)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn reindex_folder(
app: AppHandle,
db: State<'_, DbState>,
folder_id: i64,
cache_dir: String,
) -> Result<(), String> {
let folder_path = {
let conn = db.get().map_err(|e| e.to_string())?;
let folders = db::get_folders(&conn).map_err(|e| e.to_string())?;
folders
.into_iter()
.find(|f| f.id == folder_id)
.map(|f| PathBuf::from(f.path))
.ok_or("Folder not found")?
};
let cache_path = PathBuf::from(cache_dir);
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path, cache_path);
Ok(())
}
+440
View File
@@ -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(())
}
+208
View File
@@ -0,0 +1,208 @@
use crate::db::{self, DbPool, ImageRecord};
use crate::thumbnail;
use crate::vector;
use anyhow::Result;
use rayon::prelude::*;
use serde::Serialize;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Emitter};
use walkdir::WalkDir;
const IMAGE_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "avif", "heic", "heif",
];
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"];
fn is_supported_media(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| {
let ext = e.to_lowercase();
IMAGE_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str())
})
.unwrap_or(false)
}
fn media_kind_for_ext(ext: &str) -> &'static str {
match ext.to_lowercase().as_str() {
"mp4" | "mov" | "m4v" | "webm" => "video",
_ => "image",
}
}
fn mime_for_ext(ext: &str) -> &'static str {
match ext.to_lowercase().as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
"bmp" => "image/bmp",
"webp" => "image/webp",
"tiff" | "tif" => "image/tiff",
"avif" => "image/avif",
"heic" | "heif" => "image/heif",
"mp4" | "m4v" => "video/mp4",
"mov" => "video/quicktime",
"webm" => "video/webm",
_ => "image/jpeg",
}
}
#[derive(Clone, Serialize)]
pub struct IndexProgress {
pub folder_id: i64,
pub total: usize,
pub indexed: usize,
pub current_file: String,
pub done: bool,
}
#[derive(Clone, Serialize)]
pub struct IndexedImagesBatch {
pub folder_id: i64,
pub images: Vec<ImageRecord>,
}
const INDEX_BATCH_SIZE: usize = 25;
pub fn index_folder(
app: AppHandle,
pool: DbPool,
folder_id: i64,
folder_path: PathBuf,
_cache_dir: PathBuf,
) {
std::thread::spawn(move || {
if let Err(e) = do_index(app, pool, folder_id, folder_path) {
eprintln!("Indexing error: {}", e);
}
});
}
fn do_index(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
let image_paths: Vec<PathBuf> = WalkDir::new(&folder_path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file() && is_supported_media(e.path()))
.map(|e| e.path().to_path_buf())
.collect();
let total = image_paths.len();
emit_progress(
&app,
&IndexProgress {
folder_id,
total,
indexed: 0,
current_file: String::new(),
done: false,
},
);
// Parallel: read file metadata and dimensions across all CPU cores
let records: Vec<ImageRecord> = image_paths
.par_iter()
.filter_map(|path| build_record(path, folder_id))
.collect();
// Sequential: commit in batches and stream results to the frontend
let mut indexed = 0usize;
for chunk in records.chunks(INDEX_BATCH_SIZE) {
let committed = commit_batch(&pool, chunk)?;
indexed += committed.len();
emit_images(&app, &IndexedImagesBatch { folder_id, images: committed });
emit_progress(
&app,
&IndexProgress {
folder_id,
total,
indexed,
current_file: String::new(),
done: false,
},
);
}
{
let conn = pool.get()?;
db::update_folder_count(&conn, folder_id)?;
}
emit_progress(
&app,
&IndexProgress {
folder_id,
total,
indexed,
current_file: String::new(),
done: true,
},
);
Ok(())
}
fn build_record(path: &Path, folder_id: i64) -> Option<ImageRecord> {
let path_str = path.to_string_lossy().to_string();
let filename = path.file_name()?.to_string_lossy().to_string();
let meta = std::fs::metadata(path).ok();
let file_size = meta.as_ref().map(|m| m.len() as i64).unwrap_or(0);
let modified_at = meta.as_ref().and_then(|m| m.modified().ok()).map(|t| {
let dt: chrono::DateTime<chrono::Utc> = t.into();
dt.to_rfc3339()
});
let (width, height) = thumbnail::get_dimensions(path)
.map(|(w, h)| (Some(w as i64), Some(h as i64)))
.unwrap_or((None, None));
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
Some(ImageRecord {
id: 0,
folder_id,
path: path_str,
filename,
thumbnail_path: None,
width,
height,
file_size,
created_at: None,
modified_at,
mime_type: mime_for_ext(ext).to_string(),
media_kind: media_kind_for_ext(ext).to_string(),
favorite: false,
rating: 0,
embedding_status: "pending".to_string(),
embedding_model: Some(vector::CLIP_MODEL_NAME.to_string()),
embedding_updated_at: None,
embedding_error: None,
})
}
fn emit_progress(app: &AppHandle, progress: &IndexProgress) {
let _ = app.emit("index-progress", progress);
}
fn emit_images(app: &AppHandle, batch: &IndexedImagesBatch) {
let _ = app.emit("indexed-images", batch);
}
fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result<Vec<ImageRecord>> {
let mut conn = pool.get()?;
let tx = conn.transaction()?;
let mut committed = Vec::with_capacity(records.len());
for record in records {
let mut committed_record = record.clone();
committed_record.id = db::upsert_image(&tx, record)?;
db::enqueue_embedding_job(&tx, committed_record.id)?;
committed.push(committed_record);
}
tx.commit()?;
Ok(committed)
}
+45
View File
@@ -0,0 +1,45 @@
mod commands;
mod db;
mod indexer;
mod thumbnail;
mod vector;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.setup(|app| {
let app_dir = app
.path()
.app_data_dir()
.expect("Failed to get app data dir");
std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir");
let db_path = app_dir.join("gallery.db");
let pool = db::create_pool(&db_path).expect("Failed to create database pool");
{
let conn = pool.get().expect("Failed to get connection for migration");
db::migrate(&conn).expect("Failed to run migrations");
}
app.manage(pool);
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::add_folder,
commands::get_folders,
commands::remove_folder,
commands::get_images,
commands::reindex_folder,
commands::update_image_details,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
image_gallery_lib::run()
}
+6
View File
@@ -0,0 +1,6 @@
use std::path::Path;
/// Gets image dimensions without fully decoding.
pub fn get_dimensions(image_path: &Path) -> Option<(u32, u32)> {
image::image_dimensions(image_path).ok()
}
+60
View File
@@ -0,0 +1,60 @@
use anyhow::{anyhow, Result};
use rusqlite::{ffi::sqlite3_auto_extension, Connection};
use sqlite_vec::sqlite3_vec_init;
use std::sync::Once;
pub const CLIP_MODEL_NAME: &str = "openclip-vit-b-32";
pub const CLIP_VECTOR_DIM: usize = 512;
static SQLITE_VEC_INIT: Once = Once::new();
pub fn register_sqlite_vec() {
SQLITE_VEC_INIT.call_once(|| unsafe {
sqlite3_auto_extension(Some(std::mem::transmute(sqlite3_vec_init as *const ())));
});
}
pub fn migrate(conn: &Connection) -> Result<()> {
conn.execute_batch(&format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS image_vec USING vec0(
image_id INTEGER PRIMARY KEY,
embedding FLOAT[{}] distance_metric=cosine
);",
CLIP_VECTOR_DIM
))?;
Ok(())
}
#[allow(dead_code)]
pub fn delete_embedding(conn: &Connection, image_id: i64) -> Result<()> {
conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?;
Ok(())
}
#[allow(dead_code)]
pub fn upsert_embedding(conn: &Connection, image_id: i64, embedding: &[f32]) -> Result<()> {
if embedding.len() != CLIP_VECTOR_DIM {
return Err(anyhow!(
"expected {}-dimensional embedding, got {}",
CLIP_VECTOR_DIM,
embedding.len()
));
}
let packed = pack_f32(embedding);
conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?;
conn.execute(
"INSERT INTO image_vec (image_id, embedding) VALUES (?1, ?2)",
(&image_id, &packed),
)?;
Ok(())
}
#[allow(dead_code)]
fn pack_f32(values: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(values.len() * std::mem::size_of::<f32>());
for value in values {
out.extend_from_slice(&value.to_le_bytes());
}
out
}