diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/birka-cli.rs | 41 | ||||
| -rw-r--r-- | src/birka-web.rs | 162 | ||||
| -rw-r--r-- | src/database.rs | 687 |
3 files changed, 510 insertions, 380 deletions
diff --git a/src/birka-cli.rs b/src/birka-cli.rs index 3fd5420..703da7d 100644 --- a/src/birka-cli.rs +++ b/src/birka-cli.rs @@ -51,20 +51,7 @@ fn main() { std::process::exit(1); } let path = Path::new(&args[2]).canonicalize().unwrap(); - let hash = database::hash_file(&path).unwrap(); - let id = tb.import_image(&path, &hash).unwrap(); - for arg in args[3..].iter() { - tb.add_tag(arg).unwrap(); - tb.tag_image(id, arg).unwrap(); - } - } - "id_for" => { - if args.len() < 3 { - eprintln!("usage: {} id_for PATH ", args[0]); - std::process::exit(1); - } - let path = Path::new(&args[2]).canonicalize().unwrap(); - println!("{}", tb.image_id(&path).unwrap()); + let id = tb.add_file(&path, &args[3..].to_vec()).unwrap(); } "add_tags" => { if args.len() < 3 { @@ -72,10 +59,7 @@ fn main() { std::process::exit(1); } let id = args[2].parse::<i64>().unwrap(); - for arg in args[3..].iter() { - tb.add_tag(arg).unwrap(); - tb.tag_image(id, arg).unwrap(); - } + tb.tag_file(id, &args[3..].to_vec()).unwrap(); } "remove_tags" => { if args.len() < 3 { @@ -83,23 +67,16 @@ fn main() { std::process::exit(1); } let id = args[2].parse::<i64>().unwrap(); - for arg in args[3..].iter() { - tb.untag_image(id, arg).unwrap(); - } + tb.untag_file(id, &args[3..].to_vec()).unwrap(); } "query" => { - let tags = args[2..].iter().map(|s| s.as_str()).collect::<Vec<&str>>(); - for tag in tags.iter() { - if tb.tag_id(tag).is_err() { - eprintln!("unknown tag: {}", tag); - std::process::exit(1); - } + if args.len() < 3 { + eprintln!("usage: {} query QUERY_STRING", args[0]); + std::process::exit(1); } - for image in tb.images_by_tags(&tags[..]).unwrap() { - println!( - "{},{},{}{}", - image.id, image.blake2, image.orig_dir, image.filename - ); + let query = database::parse_query(&args[2]).unwrap(); + for file in tb.query(&query, None).unwrap() { + println!("{}", file.path); } } _ => { diff --git a/src/birka-web.rs b/src/birka-web.rs index 359b153..76a194f 100644 --- a/src/birka-web.rs +++ b/src/birka-web.rs @@ -31,7 +31,7 @@ extern crate serde_derive; mod database; use anyhow::Result; -use database::{Image, Query, TagDatabase}; +use database::{Keyset, TagDatabase}; use image::ImageFormat; use multipart::{MultipartFormData, MultipartFormDataField, MultipartFormDataOptions}; use rand::distributions::Alphanumeric; @@ -43,6 +43,7 @@ use rocket_contrib::json::Json; use rocket_contrib::serve::StaticFiles; use rocket_contrib::templates::Template; +use std::convert::AsRef; use std::fs; use std::fs::File; use std::io::Write; @@ -56,10 +57,10 @@ const RESULTS_PER_QUERY: i64 = 50; /// Shorthand for the state that is passed to all handlers. type SiteState<'a> = State<'a, Mutex<DatabaseConnection>>; -/// Everything necessary to serve images from the tag database. +/// Everything necessary to serve files from the tag database. struct DatabaseConnection { tb: TagDatabase, - image_dir: String, + file_dir: String, } impl DatabaseConnection { @@ -68,15 +69,15 @@ impl DatabaseConnection { // containing symbolic links and thumbnails be relative to the binary. let bin = std::env::current_exe()?.canonicalize()?; let bin_dir = bin.parent().unwrap(); - let image_dir = bin_dir.join("image/").to_str().unwrap().to_string(); + let file_dir = bin_dir.join("files/").to_str().unwrap().to_string(); let tb = TagDatabase::new(&bin_dir.join("birka.db"))?; - Ok(DatabaseConnection { tb, image_dir }) + Ok(DatabaseConnection { tb, file_dir }) } } -/// Information about an image, as returned by the 'posts' endpoint. +/// Information about a file, as returned by the 'posts' endpoint. #[derive(Debug, Serialize)] -struct ImageResult { +struct FileResult { id: i64, filename: String, thumb_filename: String, @@ -84,71 +85,84 @@ struct ImageResult { tags: Vec<String>, } -/// Return the store file name of the image at `filename`. -fn image_store_filename(id: i64, filename: &str) -> String { - if let Some(n) = filename.rfind('.') { - format!("{id}.{ext}", id = id, ext = &filename[n + 1..]) +/// Return the store file name of `filename`. +fn store_filename<T: AsRef<Path>>(id: i64, path: T) -> String { + if let Some(filename) = path.as_ref().file_name() { + let filename = filename.to_string_lossy(); + if let Some(n) = filename.rfind('.') { + format!("{id}.{ext}", id = id, ext = &filename[n + 1..]) + } else { + id.to_string() + } } else { id.to_string() } } -/// Return the store file name of the thumbnail for the image at `filename`. -fn thumb_filename(id: i64, filename: &str) -> String { - if let Some(n) = filename.rfind('.') { - format!("{id}_thumb.{ext}", id = id, ext = &filename[n + 1..]) +/// Return the store file name of the thumbnail of `filename`. +fn thumb_filename<T: AsRef<Path>>(id: i64, path: T) -> String { + if let Some(filename) = path.as_ref().file_name() { + let filename = filename.to_string_lossy(); + if let Some(n) = filename.rfind('.') { + format!("{id}_thumb.{ext}", id = id, ext = &filename[n + 1..]) + } else { + format!("{}_thumb", id) + } } else { - format!("{id}_thumb", id = id) + format!("{}_thumb", id) } } #[get("/posts?<tags>&<last>")] -fn get_posts(conn: SiteState, tags: String, last: Option<i64>) -> Result<Json<Vec<ImageResult>>> { +fn get_posts(conn: SiteState, tags: String, last: Option<i64>) -> Result<Json<Vec<FileResult>>> { let tb = &conn .inner() .lock() .map_err(|_| anyhow!("Could not lock database."))? .tb; - // Ensure that an empty vector is passed to `tb.query` if no tags were - // specified. - let tags = if !tags.is_empty() { - tags.split(",").collect::<Vec<&str>>() + let keyset = if let Some(id) = last { + Keyset { + last_id: id, + how_many: RESULTS_PER_QUERY, + } } else { - vec![] + Keyset { + last_id: i64::MAX, + how_many: RESULTS_PER_QUERY, + } }; - - let results = tb.query(Query::new(&tags[..], last, Some(RESULTS_PER_QUERY)))?; + let results = tb.query(&database::parse_query(tags)?, Some(keyset))?; Ok(Json( results .iter() - .map(|image| ImageResult { - id: image.id, - filename: image_store_filename(image.id, &image.filename), - thumb_filename: thumb_filename(image.id, &image.filename), - orig_filename: image.filename.clone(), - tags: tb.tags_for_image(image.id).unwrap(), + .map(|file| FileResult { + id: file.id, + filename: store_filename(file.id, &file.path), + thumb_filename: thumb_filename(file.id, &file.path), + orig_filename: file.path.clone(), + tags: file.tags.clone(), }) .collect(), )) } #[get("/posts/<id>")] -fn get_post(conn: SiteState, id: i64) -> Result<Json<ImageResult>> { +fn get_post(conn: SiteState, id: i64) -> Result<Json<FileResult>> { let tb = &conn .inner() .lock() .map_err(|_| anyhow!("Could not lock database."))? .tb; - let image = tb.image_by_id(id)?; + let file = tb.file_by_id(id)?; - Ok(Json(ImageResult { - id: image.id, - filename: image_store_filename(image.id, &image.filename), - thumb_filename: thumb_filename(image.id, &image.filename), - orig_filename: image.filename.clone(), - tags: tb.tags_for_image(image.id)?, + Ok(Json(FileResult { + id: file.id, + filename: store_filename(file.id, &file.path), + thumb_filename: thumb_filename(file.id, &file.path), + orig_filename: file.path.clone(), + tags: file.tags, })) } @@ -164,12 +178,7 @@ fn update_post(conn: SiteState, id: i64, form: Form<UpdateTags>) -> Result<Json< .lock() .map_err(|_| anyhow!("Could not lock database."))? .tb; - - for tag in form.tags.split(",") { - tb.add_tag(tag)?; - tb.tag_image(id, tag)?; - } - + tb.tag_file(id, &form.tags.split(",").collect())?; Ok(Json(String::from("Updated!"))) } @@ -196,31 +205,18 @@ fn put_post(conn: SiteState, content_type: &ContentType, data: Data) -> Result<J let filename = raw .file_name .unwrap_or(thread_rng().sample_iter(&Alphanumeric).take(30).collect()); - let path = Path::new(&conn.image_dir).join(&filename); + let path = Path::new(&conn.file_dir).join(&filename); let mut f = File::create(&path)?; f.write_all(&raw.raw)?; - let hash = database::hash_file(&path)?; - let id = conn.tb.import_image(&path, &hash)?; - add_to_store( - &Image { - id, - blake2: hash, - filename: filename.clone(), - orig_dir: conn.image_dir.clone(), - }, - &conn.image_dir, - ); - - if let Some(mut vec) = data.texts.remove("tags") { - if vec.len() == 1 { - let tags = vec.remove(0).text; - for tag in tags.split(',') { - conn.tb.add_tag(tag)?; - conn.tb.tag_image(id, tag)?; - } - } - } + let tags = if let Some(mut vec) = data.texts.remove("tags") { + let text = vec.remove(0).text; + text.split(',').map(|tag| String::from(tag)).collect() + } else { + vec![] + }; + let file = conn.tb.add_file(&path, &tags)?; + add_to_store(&file, &conn.file_dir); Ok(Json("Uploaded!".into())) } @@ -246,7 +242,7 @@ fn display_posts(conn: SiteState, tags: String, last: Option<i64>) -> Result<Tem #[derive(Serialize)] struct Context { tags: Vec<TagResult>, - images: Vec<ImageResult>, + images: Vec<FileResult>, next_page: String, } @@ -298,27 +294,26 @@ fn display_post(conn: SiteState, id: i64) -> Result<Template> { .lock() .map_err(|_| anyhow!("Could not lock database."))? .tb; - let image = tb.image_by_id(id)?; + let file = tb.file_by_id(id)?; Ok(Template::render( "image", Context { id, - tags: tb.tags_for_image(image.id)?, - filename: image_store_filename(image.id, &image.filename), - orig_filename: image.filename, + tags: file.tags, + filename: store_filename(file.id, &file.path), + orig_filename: file.path, }, )) } -/// Create a thumbnail for `image` in the store at `image_dir`. -fn add_to_store(image: &Image, image_dir: &str) { - let orig_path = Path::new(&image.orig_dir).join(&image.filename); - let store_path = image_store_filename(image.id, &image.filename); - let store_path = Path::new(image_dir).join(store_path); - symlink(orig_path, &store_path).ok(); - let thumb_path = thumb_filename(image.id, &image.filename); - let thumb_path = Path::new(image_dir).join(&thumb_path); +/// Create a thumbnail for `file` in the store at `file_dir`. +fn add_to_store(file: &database::File, file_dir: &str) { + let store_path = store_filename(file.id, &file.path); + let store_path = Path::new(file_dir).join(store_path); + symlink(&file.path, &store_path).ok(); + let thumb_path = thumb_filename(file.id, &file.path); + let thumb_path = Path::new(file_dir).join(&thumb_path); if !thumb_path.exists() { let im = image::open(store_path).unwrap(); let out = &mut File::create(&thumb_path).unwrap(); @@ -330,9 +325,10 @@ fn add_to_store(image: &Image, image_dir: &str) { /// Maybe initialize the directory for storing image symlinks and thumbnails. fn create_image_directory(conn: &DatabaseConnection) { - fs::create_dir(&conn.image_dir).ok(); - for image in conn.tb.images_by_tags(&vec![][..]).unwrap() { - add_to_store(&image, &conn.image_dir); + fs::create_dir(&conn.file_dir).ok(); + let query = database::parse_query("").unwrap(); + for file in conn.tb.query(&query, None).unwrap() { + add_to_store(&file, &conn.file_dir); } } @@ -341,7 +337,7 @@ fn main() { create_image_directory(&conn); rocket::ignite() .mount("/public", StaticFiles::from("./static/")) - .mount("/image", StaticFiles::from(&conn.image_dir)) + .mount("/image", StaticFiles::from(&conn.file_dir)) .mount("/", routes![index, upload, display_posts, display_post]) .mount("/api", routes![get_posts, get_post, put_post, update_post]) .attach(Template::fairing()) diff --git a/src/database.rs b/src/database.rs index 14ff853..bd5ae65 100644 --- a/src/database.rs +++ b/src/database.rs @@ -16,93 +16,96 @@ // License along with бирка-тян. If not, see <http://www.gnu.org/licenses/>. use anyhow::Result; -use blake2::{Blake2b, Digest}; use rusqlite::Connection; use std::convert::AsRef; -use std::io; use std::path::Path; -/// A connection to the tag database. -#[derive(Debug)] -pub struct TagDatabase(Connection); - -/// Tag query with a sense of "state" for pagination. -#[derive(Debug)] -pub struct Query<'a> { - tags: &'a [&'a str], - last_id: Option<i64>, - limit: Option<i64>, +/// Type of constraint on a column. +#[derive(Debug, PartialEq)] +pub enum Atom { + Is(String), + IsNot(String), } -impl<'a> Query<'a> { - /// Construct a new query for images tagged with `tags`. - /// - /// When given to the database, this query will return at most `limit` - /// results, if specified, and will return only images newer than `last_id`, - /// if specified. - pub fn new(tags: &'a [&'a str], last_id: Option<i64>, limit: Option<i64>) -> Self { - Query { - tags, - last_id, - limit, - } - } +/// One of the addressable columns of the 'files' table. +#[derive(Debug, PartialEq)] +pub enum Field { + Tag(Atom), + Filename(Atom), } -/// The result of an image query in бирка-тян's tag database. -#[derive(Debug)] -pub struct Image { - pub id: i64, - pub blake2: String, - pub filename: String, - pub orig_dir: String, -} - -/// Return a base64-encoded BLAKE2b hash identifying the file at `path`. -pub fn hash_file(path: &Path) -> Result<String> { - let mut file = std::fs::File::open(path)?; - let mut hasher = Blake2b::new(); - io::copy(&mut file, &mut hasher)?; - Ok(base64::encode(&hasher.result())) -} +use Atom::{Is, IsNot}; +use Field::{Filename, Tag}; -/// Split the path into a tuple containing the parent directory and the filename. -pub fn split_path(path: &Path) -> Result<(String, String)> { - if path.is_dir() { - bail!("`path` does not name a file."); - } +/// A logical conjunction of `Field` contraints. +/// +/// This is the general interface for queries to the tag database. +pub type Query = Vec<Field>; - let file_name = path - .file_name() - .and_then(|os| os.to_str()) - .ok_or(anyhow!("Couldn't parse file name."))?; +/// Parse `query_string` into a `Query` object usable with the tag database. +pub fn parse_query<T: AsRef<str>>(query_string: T) -> Result<Query> { + Ok(query_string + .as_ref() + .split_whitespace() + .map(|part| { + if part.starts_with("-") { + if part.len() >= 9 && &part[1..10] == "filename:" { + Filename(IsNot(String::from(&part[10..]))) + } else { + Tag(IsNot(String::from(&part[1..]))) + } + } else { + if part.len() >= 9 && &part[..9] == "filename:" { + Filename(Is(String::from(&part[9..]))) + } else { + Tag(Is(String::from(part))) + } + } + }) + .collect()) +} - // Convert `path` to a string and strip off the `file_name` we've just - // obtained to yield the name of the parent directory. - let path = path.to_str().ok_or(anyhow!("Couldn't parse path."))?; - let split_index = path.len() - file_name.len(); - let parent_directory = &path[0..split_index]; +/// State object representing a point in a paginated query. +pub struct Keyset { + pub last_id: i64, + pub how_many: i64, +} - Ok((file_name.to_string(), parent_directory.to_string())) +/// Information about an file in the database. +/// +/// The result of any queries made to the tag database. `id` is the canonical +/// representation of the file, `path` is the path of the file in the +/// filesystem, and `tags` is a vector containing all of the strings the file is +/// tagged with. +pub struct File { + pub id: i64, + pub path: String, + pub tags: Vec<String>, } +/// Public interface to the tag database. +/// +/// Essentially, a wrapper around `rusqlite::Connection` that allows only +/// queries which are sensical given the purpose of the tag databse. +pub struct TagDatabase(Connection); + impl TagDatabase { - /// Instantiate a new `TagDatabase` whose backing database is at `path`. - pub fn new<T: AsRef<Path>>(path: &T) -> Result<Self> { + /// Return a `TagDatabase` connected to `path`. + /// + /// This constructor will create the file if it does not exist. + pub fn new<T: AsRef<Path>>(path: T) -> Result<TagDatabase> { let tb = TagDatabase(Connection::open(path)?); tb.initialize_tables()?; Ok(tb) } - /// Create the table structure for the tag database. + /// Initialize the tag database's schema. fn initialize_tables(&self) -> Result<()> { self.0.execute( - "CREATE TABLE IF NOT EXISTS images ( - id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - blake2 TEXT NOT NULL, - filename TEXT NOT NULL, - orig_dir TEXT NOT NULL + "CREATE TABLE IF NOT EXISTS files ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL )", params![], )?; @@ -115,196 +118,235 @@ impl TagDatabase { )?; self.0.execute( "CREATE TABLE IF NOT EXISTS mapping ( - image INTEGER NOT NULL, - tag INTEGER NOT NULL + file INTEGER NOT NULL, + tag INTEGER NOT NULL )", params![], )?; Ok(()) } - /// Insert `tag` into the database, if it does not already exist. - pub fn add_tag(&self, tag: &str) -> Result<()> { - let exists = self + /// Return all tags associated with the file named by `id`. + fn tags_for_file(&self, id: i64) -> Result<Vec<String>> { + Ok(self .0 - .prepare("SELECT * FROM tags WHERE name = ?")? - .exists(params![tag])?; - if !exists { - self.0 - .execute("INSERT INTO tags (name) VALUES(?)", params![tag])?; - } - Ok(()) + .prepare( + "SELECT tags.name + FROM mapping + INNER JOIN tags ON mapping.tag = tags.id + WHERE mapping.file = ?;", + )? + .query_map(params![id], |row| row.get(0))? + .filter_map(|tag| tag.ok()) + .collect()) } - /// Return the internal id for `tag`. - pub fn tag_id(&self, tag: &str) -> Result<i64> { + /// Return the id of the file located at `path`. + fn id_for_path<T: AsRef<Path>>(&self, path: T) -> Result<i64> { + let path = String::from(path.as_ref().to_str().unwrap()); Ok(self .0 - .prepare("SELECT * FROM tags WHERE name = ?")? - .query_row(params![tag], |row| Ok(row.get::<_, i64>(0)))??) + .prepare("SELECT id FROM files WHERE path = ?")? + .query_row(params![path], |row| Ok(row.get::<_, i64>(0)))??) } - /// Insert the image at `path` into the data. - pub fn import_image(&self, path: &Path, hash: &str) -> Result<i64> { - let (file_name, parent_directory) = split_path(path)?; - Ok(self + /// Index the file at `path` in the tag database. + pub fn add_file<T, S>(&self, path: T, tags: &Vec<S>) -> Result<File> + where + T: AsRef<Path>, + S: AsRef<str>, + { + if self.id_for_path(&path).is_ok() { + bail!("'{:?}' already indexed by the tag database.", path.as_ref()) + } + + // Insert the file row. + self.0 + .prepare("INSERT INTO files (path) VALUES(?)")? + .insert(params![path.as_ref().to_str().unwrap()])?; + + // Associate each tag with the file, creating a row for the tag if it + // does not yet exist in the database. + + let path = String::from(path.as_ref().to_str().unwrap()); + let id = self .0 - .prepare("INSERT INTO images (blake2, filename, orig_dir) VALUES(?,?,?)")? - .insert(params![hash, file_name, parent_directory])?) + .prepare("SELECT id FROM files WHERE path = ?")? + .query_row(params![path], |row| Ok(row.get::<_, i64>(0)))??; + self.tag_file(id, tags)?; + + let tags = self.tags_for_file(id)?; + Ok(File { id, path, tags }) } - /// Return the internal id for the image at `path`. - pub fn image_id(&self, path: &Path) -> Result<i64> { - let (file_name, path) = split_path(path)?; + /// Remove the file named by `id` from the tag database. + pub fn remove_file(&self, id: i64) -> Result<()> { + // Remove the file from the `files` table, + self.0 + .execute("DELETE FROM files WHERE id = ?", params![id])?; + // but also remove it from the table for mapping tags. + self.0 + .execute("DELETE FROM mapping WHERE file = ?", params![id])?; + Ok(()) + } + + /// Return the identifier for `tag`, or `Err` if no such tag exists. + fn tag_id<T: AsRef<str>>(&self, tag: T) -> Result<i64> { Ok(self .0 - .prepare("SELECT id FROM images WHERE orig_dir = ? AND filename = ?")? - .query_row(params![path, file_name], |row| Ok(row.get::<_, i64>(0)))??) + .prepare("SELECT * FROM tags WHERE name = ?")? + .query_row(params![tag.as_ref()], |row| Ok(row.get::<_, i64>(0)))??) } - /// Return the `Image` struct for the image with `id`. - pub fn image_by_id(&self, id: i64) -> Result<Image> { + /// Return the identifiers for all tags named similarly to `tag`. + /// + /// This function treats `tag` as a wildcard, and therefore only makes sense + /// in the context of a query. For inserting images to the database, or any + /// other action where a "canonical" tag is desired, reach for `tag_id` + /// instead. + fn tag_ids<T: AsRef<str>>(&self, tag: T) -> Result<Vec<i64>> { Ok(self .0 - .prepare( - "SELECT id, blake2, filename, orig_dir - FROM images - WHERE id = ?", - )? - .query_row(params![id], |row| { - Ok(Image { - id: row.get(0)?, - blake2: row.get(1)?, - filename: row.get(2)?, - orig_dir: row.get(3)?, - }) - })?) + .prepare("SELECT id FROM tags WHERE name LIKE ?")? + .query_map(params![tag.as_ref().replace("*", "%")], |row| row.get(0))? + .filter_map(|id| id.ok()) + .collect()) } - /// Associate `tag` with the image specified by `image_id`. - pub fn tag_image(&self, image_id: i64, tag: &str) -> Result<()> { - self.0.execute( - "INSERT INTO mapping (image, tag) VALUES(?, ?)", - params![image_id, self.tag_id(tag)?], - )?; + /// Associate all of `tags` with the file named by `id`. + /// + /// If any tag lacks a row in the database, it will be created. The contents + /// of `tags` should not contain wildcardcard expressions. + pub fn tag_file<T: AsRef<str>>(&self, id: i64, tags: &Vec<T>) -> Result<()> { + for tag in tags.iter() { + // Ensure that a row for `tag` into the database. + if self.tag_id(tag.as_ref()).is_err() { + self.0 + .execute("INSERT INTO tags (name) VALUES(?)", params![tag.as_ref()])?; + } + self.0.execute( + "INSERT INTO mapping (file, tag) VALUES(?, ?)", + params![id, self.tag_id(tag.as_ref()).unwrap()], + )?; + } Ok(()) } - /// Disassociate `tag` with the image specified by `image_id`. - pub fn untag_image(&self, image_id: i64, tag: &str) -> Result<()> { - self.0.execute( - "DELETE FROM mapping WHERE image = ? AND tag = ?", - params![image_id, self.tag_id(tag)?], - )?; + /// Remove all of `tags` from the file named by `id`. + /// + /// The contents of `tags` can contain wildcardcard expressions. + pub fn untag_file<T: AsRef<str>>(&self, id: i64, tags: &Vec<T>) -> Result<()> { + for tag in tags.iter() { + // Silently skip any tags which do not exist in the database. + if let Ok(tags) = self.tag_ids(tag.as_ref()) { + for tag in tags.iter() { + self.0 + .prepare("DELETE FROM mapping WHERE file = ? AND tag = ?")? + .insert(params![id, tag])?; + } + } + } Ok(()) } - /// Return the tags for the image specified by `image_id`. - pub fn tags_for_image(&self, image_id: i64) -> Result<Vec<String>> { + /// Return the `File` object for the file named by `id`. + pub fn file_by_id(&self, id: i64) -> Result<File> { + let tags = self.tags_for_file(id)?; Ok(self .0 - .prepare( - "SELECT tags.name - FROM mapping - INNER JOIN tags ON mapping.tag = tags.id - WHERE mapping.image = ?;", - )? - .query_map(params![image_id], |row| row.get(0))? - .filter_map(|tag| tag.ok()) - .collect()) + .prepare("SELECT id, path FROM files WHERE id = ?")? + .query_row(params![id], |row| { + Ok(File { + id: row.get(0)?, + path: row.get(1)?, + tags, + }) + })?) } - /// Return the intersection of the sets of images matching each of `tags`. - pub fn query(&self, q: Query) -> Result<Vec<Image>> { - let ids: Vec<i64> = q - .tags - .iter() - .filter_map(|tag| self.tag_id(tag).ok()) - .collect(); - let mut tags = Vec::from(q.tags); - tags.dedup(); - - // Query contained a tag that's not in the database. - if ids.len() != tags.len() { - return Ok(vec![]); + /// Return the files in the database satisfying `q` and `k`. + pub fn query(&self, q: &Query, k: Option<Keyset>) -> Result<Vec<File>> { + // Return an empty vector if the query contains tags that aren't in the + // database. + for part in q.iter() { + if let Tag(Is(name)) = part { + if self.tag_id(name).is_err() { + return Ok(vec![]); + } + } } - let union = if tags.is_empty() { - None - } else { - Some( - (1..ids.len() + 1) - .zip(ids) - .map(|pair| { - let (n, id) = pair; - format!( - "INNER JOIN mapping as m{n} - ON images.id = m{n}.image - AND {id} = m{n}.tag", - n = n, - id = id - ) - }) - .collect::<Vec<String>>() - .join("\n"), - ) - }; + let predicates = q + .iter() + .filter_map(|field| match field { + Tag(Is(expression)) => Some( + self.tag_ids(expression) + .ok()? + .iter() + .map(|tag| format!("(mapping.tag = {})", tag)) + .collect::<Vec<_>>(), + ), + Tag(IsNot(expression)) => Some( + self.tag_ids(expression) + .ok()? + .iter() + .map(|tag| { + format!( + "(files.id NOT IN + (SELECT files.id FROM files + JOIN mapping ON files.id = mapping.file + WHERE mapping.tag = {}))", + tag + ) + }) + .collect::<Vec<_>>(), + ), + Filename(Is(expression)) => Some(vec![format!( + "(file.path LIKE %{})", + expression.replace("*", "%") + )]), + Filename(IsNot(expression)) => Some(vec![format!( + "(file.path NOT LIKE %{})", + expression.replace("*", "%") + )]), + }) + .flat_map(|s| s) + .collect::<Vec<_>>(); let query = format!( - "SELECT id, blake2, filename, orig_dir - FROM images - {union} - {since} - GROUP BY id - ORDER BY id DESC - {limit}", - since = if let Some(n) = q.last_id { - format!("WHERE id < {}", n) + "SELECT files.id FROM files + JOIN mapping + ON files.id = mapping.file + WHERE {predicate} + {since} + GROUP BY files.id + ORDER BY files.id DESC + {limit}", + predicate = if predicates.is_empty() { + "1 == 1".into() } else { - String::from("") + predicates.join(" AND ") }, - limit = if let Some(n) = q.limit { - format!("LIMIT {}", n) + since = if let Some(ref keyset) = k { + format!("AND files.id < {}", keyset.last_id) } else { - String::from("") + "".into() }, - union = union.or(Some("".into())).unwrap(), + limit = if let Some(ref keyset) = k { + format!("LIMIT {}", keyset.how_many) + } else { + "".into() + } ); Ok(self .0 .prepare(&query)? - .query_map( - params![], - |row| -> rusqlite::Result<(i64, String, String, String)> { - Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) - }, - )? - .filter_map(|res| { - res.map(|pair| { - let (id, blake2, filename, orig_dir) = pair; - Image { - id, - blake2, - filename, - orig_dir, - } - }) - .ok() - }) + .query_map(params![], |row| row.get::<_, i64>(0))? + .filter_map(|id| id.ok().and_then(|id| self.file_by_id(id).ok())) .collect()) } - /// Return the intersection of the sets of images matching each of `tags`. - pub fn images_by_tags(&self, tags: &[&str]) -> Result<Vec<Image>> { - self.query(Query { - tags, - last_id: None, - limit: None, - }) - } - - /// Return the `n` greatest tags, ordered by number of associated images. pub fn top_tags(&self, n: i64) -> Result<Vec<(i64, String)>> { Ok(self .0 @@ -337,95 +379,210 @@ mod tests { } #[test] - fn tables_exist() { + fn parse_query_empty() { + let parsed = parse_query("").unwrap(); + assert!(parsed.len() == 0); + } + + #[test] + fn parse_query_tags_only() { + let parsed = parse_query("tag1 tag2 tag3").unwrap(); + assert_eq!(parsed.len(), 3); + assert!(parsed.contains(&Tag(Is("tag1".into())))); + assert!(parsed.contains(&Tag(Is("tag2".into())))); + assert!(parsed.contains(&Tag(Is("tag3".into())))); + } + + #[test] + fn parse_query_tag_negations_only() { + let parsed = parse_query("-tag1 -tag2 -tag3").unwrap(); + assert_eq!(parsed.len(), 3); + assert!(parsed.contains(&Tag(IsNot("tag1".into())))); + assert!(parsed.contains(&Tag(IsNot("tag2".into())))); + assert!(parsed.contains(&Tag(IsNot("tag3".into())))); + } + + #[test] + fn parse_query_filenames_only() { + let parsed = parse_query("filename:1 filename:2 filename:3").unwrap(); + assert_eq!(parsed.len(), 3); + assert!(parsed.contains(&Filename(Is("1".into())))); + assert!(parsed.contains(&Filename(Is("2".into())))); + assert!(parsed.contains(&Filename(Is("3".into())))); + } + + #[test] + fn parse_query_filename_negations_only() { + let parsed = parse_query("-filename:1 -filename:2 -filename:3").unwrap(); + assert_eq!(parsed.len(), 3); + assert!(parsed.contains(&Filename(IsNot("1".into())))); + assert!(parsed.contains(&Filename(IsNot("2".into())))); + assert!(parsed.contains(&Filename(IsNot("3".into())))); + } + + #[test] + fn parse_query_combined() { + let parsed = parse_query("tag1 -tag2 filename:1 -filename:2").unwrap(); + assert_eq!(parsed.len(), 4); + assert!(parsed.contains(&Tag(Is("tag1".into())))); + assert!(parsed.contains(&Tag(IsNot("tag2".into())))); + assert!(parsed.contains(&Filename(Is("1".into())))); + assert!(parsed.contains(&Filename(IsNot("2".into())))); + } + + #[test] + fn test_add_file_fail_on_already_indexed() { let tb = TagDatabase::new_mem().unwrap(); - for table in ["images", "tags", "mapping"].iter() { - let exists = - tb.0.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?;") - .unwrap() - .exists(params![table]) - .unwrap(); - assert!(exists); - } + let img = tb.add_file("test", &vec!["tag1"]).unwrap(); + assert!(tb.add_file("test", &vec!["tag2"]).is_err()); } #[test] - fn insert_file() { + fn test_add_file_output_reflects_input() { let tb = TagDatabase::new_mem().unwrap(); - let id = tb - .import_image(Path::new("/fake/path"), "fakehash") - .unwrap(); - let exists = - tb.0.prepare("SELECT filename, blake2 FROM images WHERE id = ?;") - .unwrap() - .exists(params![id]) - .unwrap(); - assert!(exists); - let exists = - tb.0.prepare("SELECT filename, blake2 FROM images WHERE id = ?;") - .unwrap() - .exists(params![id + 1]) - .unwrap(); - assert!(!exists); + let img = tb.add_file("test", &vec!["tag1", "tag2"]).unwrap(); + assert_eq!(img.path, "test"); + assert_eq!(img.tags.len(), 2); + assert!(img.tags.contains(&String::from("tag1"))); + assert!(img.tags.contains(&String::from("tag2"))); + } + + #[test] + fn test_remove_file() { + let tb = TagDatabase::new_mem().unwrap(); + let img = tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + tb.remove_file(img.id).unwrap(); + + let query = parse_query("tag1").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 0); + } + + #[test] + fn test_query_tags_only() { + let tb = TagDatabase::new_mem().unwrap(); + tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + tb.add_file("test2", &vec!["tag2"]).unwrap(); + + let query = parse_query("tag1").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "test1"); + + let query = parse_query("tag2").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 2); + } + + #[test] + fn test_query_tags_not_indexed() { + let tb = TagDatabase::new_mem().unwrap(); + tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + + let query = parse_query("tag3").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 0); } #[test] - fn add_tag() { + fn test_query_tag_negations_only() { let tb = TagDatabase::new_mem().unwrap(); - tb.add_tag("test").unwrap(); - tb.tag_id("test").unwrap(); + tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + tb.add_file("test2", &vec!["tag2"]).unwrap(); + + let query = parse_query("-tag1").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "test2"); } #[test] - fn tag_image() { + fn test_query_tags_with_keyset() { let tb = TagDatabase::new_mem().unwrap(); - let id = tb - .import_image(Path::new("/fake/path"), "fakehash") + tb.add_file("test1", &vec!["tag1"]).unwrap(); + tb.add_file("test2", &vec!["tag1"]).unwrap(); + + let query = parse_query("tag1").unwrap(); + let results = tb + .query( + &query, + Some(Keyset { + last_id: 3, + how_many: 1, + }), + ) .unwrap(); - tb.add_tag("test").unwrap(); - tb.tag_image(id, "test").unwrap(); - let tags = tb.tags_for_image(id).unwrap(); - assert_eq!(tags.len(), 1); - assert!(tags.contains(&String::from("test"))); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "test2"); + + let results = tb + .query( + &query, + Some(Keyset { + last_id: 2, + how_many: 1, + }), + ) + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "test1"); } #[test] - fn query_by_tag() { + fn test_empty_query() { let tb = TagDatabase::new_mem().unwrap(); - let ids: Vec<i64> = (1..4) - .map(|i| { - let i = i.to_string(); - tb.import_image(Path::new(&i), &i).unwrap() - }) - .collect(); - tb.add_tag("1").unwrap(); - tb.add_tag("2").unwrap(); - tb.tag_image(ids[0], "1").unwrap(); - tb.tag_image(ids[1], "2").unwrap(); - tb.tag_image(ids[2], "1").unwrap(); - tb.tag_image(ids[2], "2").unwrap(); - assert_eq!(tb.images_by_tags(&["1"]).unwrap().len(), 2); - assert_eq!(tb.images_by_tags(&["2"]).unwrap().len(), 2); - assert_eq!(tb.images_by_tags(&["1", "2"]).unwrap().len(), 1); + tb.add_file("test1", &vec!["tag1"]).unwrap(); + tb.add_file("test2", &vec!["tag1"]).unwrap(); + + let query = parse_query("").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 2); + } + + #[test] + fn test_tag_file() { + let tb = TagDatabase::new_mem().unwrap(); + let img = tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + tb.tag_file(img.id, &vec!["tag3"]).unwrap(); + + let query = parse_query("tag3").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].path, "test1"); + } + + #[test] + fn test_untag_file() { + let tb = TagDatabase::new_mem().unwrap(); + let img = tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + tb.untag_file(img.id, &vec!["tag2"]).unwrap(); + + let query = parse_query("tag2").unwrap(); + let results = tb.query(&query, None).unwrap(); + assert_eq!(results.len(), 0); + } + + #[test] + fn test_id_resolution() { + let tb = TagDatabase::new_mem().unwrap(); + let img = tb.add_file("test1", &vec!["tag1", "tag2"]).unwrap(); + assert_eq!(tb.file_by_id(img.id).unwrap().path, "test1"); } #[test] fn top_tags() { let tb = TagDatabase::new_mem().unwrap(); - let ids: Vec<i64> = (1..4) + let ids: Vec<_> = (1..4) .map(|i| { let i = i.to_string(); - tb.import_image(Path::new(&i), &i).unwrap() + tb.add_file(Path::new(&i), &Vec::<&str>::new()).unwrap() }) .collect(); - tb.add_tag("1").unwrap(); - tb.add_tag("2").unwrap(); - tb.tag_image(ids[0], "1").unwrap(); - tb.tag_image(ids[1], "2").unwrap(); - tb.tag_image(ids[2], "1").unwrap(); - tb.tag_image(ids[2], "2").unwrap(); + tb.tag_file(ids[0].id, &vec!["1"]).unwrap(); + tb.tag_file(ids[1].id, &vec!["2"]).unwrap(); + tb.tag_file(ids[2].id, &vec!["1"]).unwrap(); + tb.tag_file(ids[2].id, &vec!["2"]).unwrap(); assert_eq!(tb.top_tags(2).unwrap().len(), 2); assert_eq!(tb.top_tags(2).unwrap()[0].0, 2); - assert_eq!(tb.top_tags(2).unwrap()[1].0, 2); } } |