use anyhow::Result;
use rusqlite::Connection;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Marks {
pub rating: Option<i64>,
pub pick: Option<Pick>,
pub label: Option<String>,
pub liked: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pick {
Keep,
Reject,
}
impl Pick {
pub fn as_bool(self) -> i64 {
match self {
Pick::Keep => 1,
Pick::Reject => 0,
}
}
pub fn from_bool(v: i64) -> Pick {
if v == 0 {
Pick::Reject
} else {
Pick::Keep
}
}
}
#[derive(Debug, Clone)]
pub enum Field<T> {
Set(T),
Clear,
}
#[derive(Debug, Clone, Default)]
pub struct MarkChange {
pub rating: Option<Field<i64>>,
pub pick: Option<Field<Pick>>,
pub label: Option<Field<String>>,
pub liked: Option<bool>,
}
impl MarkChange {
pub fn any(&self) -> bool {
self.rating.is_some() || self.pick.is_some() || self.label.is_some() || self.liked.is_some()
}
}
pub fn change_from_parts(
rating: Option<i64>,
pick: Option<&str>,
label: Option<&str>,
liked: Option<bool>,
) -> MarkChange {
MarkChange {
rating: rating.map(|r| if r == 0 { Field::Clear } else { Field::Set(r) }),
pick: pick.map(|p| match p {
"keep" => Field::Set(Pick::Keep),
"reject" => Field::Set(Pick::Reject),
_ => Field::Clear, }),
label: label.map(|l| {
if l == "none" {
Field::Clear
} else {
Field::Set(l.to_string())
}
}),
liked,
}
}
pub fn ensure_marks_table(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS marks (
hash TEXT PRIMARY KEY,
rating INTEGER,
pick INTEGER,
label TEXT,
liked INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);",
)?;
Ok(())
}
pub fn get(conn: &Connection, hash: &str) -> Result<Marks> {
let row = conn
.query_row(
"SELECT rating, pick, label, liked FROM marks WHERE hash = ?1",
[hash],
|r| {
Ok(Marks {
rating: r.get::<_, Option<i64>>(0)?,
pick: r.get::<_, Option<i64>>(1)?.map(Pick::from_bool),
label: r.get::<_, Option<String>>(2)?,
liked: r.get::<_, i64>(3)? != 0,
})
},
)
.ok();
Ok(row.unwrap_or_default())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
pub struct MarksSummary {
pub rated: i64,
pub picked: i64,
pub labelled: i64,
pub liked: i64,
}
pub fn summary(conn: &Connection) -> rusqlite::Result<MarksSummary> {
if !crate::db::table_exists(conn, "marks")? {
return Ok(MarksSummary::default());
}
Ok(conn.query_row(
"SELECT COUNT(rating), COUNT(pick), COUNT(label), COALESCE(SUM(liked), 0) FROM marks",
[],
|r| {
Ok(MarksSummary {
rated: r.get(0)?,
picked: r.get(1)?,
labelled: r.get(2)?,
liked: r.get(3)?,
})
},
)?)
}
pub fn get_many(conn: &Connection, hashes: &[String]) -> Result<HashMap<String, Marks>> {
let mut out = HashMap::new();
for h in hashes {
let m = get(conn, h)?;
if m != Marks::default() {
out.insert(h.clone(), m);
}
}
Ok(out)
}
pub fn set(conn: &Connection, hashes: &[String], change: &MarkChange) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for h in hashes {
let mut m = get(&tx, h)?;
if let Some(f) = &change.rating {
m.rating = match f {
Field::Set(v) => Some((*v).clamp(0, 5)),
Field::Clear => None,
};
if m.rating == Some(0) {
m.rating = None; }
}
if let Some(f) = &change.pick {
m.pick = match f {
Field::Set(p) => Some(*p),
Field::Clear => None,
};
}
if let Some(f) = &change.label {
m.label = match f {
Field::Set(s) => Some(s.clone()),
Field::Clear => None,
};
}
if let Some(v) = change.liked {
m.liked = v;
}
let empty = m.rating.is_none() && m.pick.is_none() && m.label.is_none() && !m.liked;
if empty {
tx.execute("DELETE FROM marks WHERE hash = ?1", [h])?;
} else {
tx.execute(
"INSERT INTO marks (hash, rating, pick, label, liked, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'))
ON CONFLICT(hash) DO UPDATE SET
rating = ?2, pick = ?3, label = ?4, liked = ?5, updated_at = datetime('now')",
rusqlite::params![
h,
m.rating,
m.pick.map(Pick::as_bool),
m.label,
m.liked as i64,
],
)?;
}
}
tx.commit()?;
Ok(())
}
pub fn by_rating(conn: &Connection, min: i64) -> Result<HashSet<String>> {
hashes(conn, "SELECT hash FROM marks WHERE rating >= ?1", [min])
}
pub fn by_pick(conn: &Connection, pick: Pick) -> Result<HashSet<String>> {
hashes(
conn,
"SELECT hash FROM marks WHERE pick = ?1",
[pick.as_bool()],
)
}
pub fn by_label(conn: &Connection, label: &str) -> Result<HashSet<String>> {
hashes(conn, "SELECT hash FROM marks WHERE label = ?1", [label])
}
pub fn by_liked(conn: &Connection) -> Result<HashSet<String>> {
hashes(conn, "SELECT hash FROM marks WHERE liked = 1", [])
}
fn hashes<P: rusqlite::Params>(conn: &Connection, sql: &str, p: P) -> Result<HashSet<String>> {
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(p, |r| r.get::<_, String>(0))?;
Ok(rows.collect::<rusqlite::Result<HashSet<String>>>()?)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum XmpPrecedence {
#[default]
Db,
File,
Newest,
}
impl XmpPrecedence {
pub fn parse(s: &str) -> Result<Self> {
match s {
"db" => Ok(Self::Db),
"file" => Ok(Self::File),
"newest" => Ok(Self::Newest),
other => anyhow::bail!("unknown --xmp value {other:?}; expected db, file, or newest"),
}
}
}
pub fn import_change(
existing: &Marks,
xmp_rating: Option<i64>,
xmp_label: Option<String>,
prec: XmpPrecedence,
) -> Option<MarkChange> {
let file_wins = matches!(prec, XmpPrecedence::File);
let want = |db_has: bool| file_wins || !db_has;
let mut c = MarkChange::default();
if let Some(r) = xmp_rating {
if want(existing.rating.is_some()) {
c.rating = Some(Field::Set(r));
}
}
if let Some(l) = xmp_label {
if want(existing.label.is_some()) {
c.label = Some(Field::Set(l));
}
}
if c.any() {
Some(c)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mem() -> Connection {
let c = Connection::open_in_memory().unwrap();
c.execute_batch("CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);")
.unwrap();
ensure_marks_table(&c).unwrap();
c
}
#[test]
fn empty_change_touches_nothing() {
assert!(!MarkChange::default().any());
}
#[test]
fn change_from_parts_maps_the_request_shapes() {
let c = change_from_parts(Some(0), Some("none"), Some("none"), Some(false));
assert!(matches!(c.rating, Some(Field::Clear)));
assert!(matches!(c.pick, Some(Field::Clear)));
assert!(matches!(c.label, Some(Field::Clear)));
assert_eq!(c.liked, Some(false));
let c = change_from_parts(Some(4), Some("reject"), Some("Red"), None);
assert!(matches!(c.rating, Some(Field::Set(4))));
assert!(matches!(c.pick, Some(Field::Set(Pick::Reject))));
assert!(matches!(c.label, Some(Field::Set(ref s)) if s == "Red"));
assert_eq!(c.liked, None);
assert!(change_from_parts(None, None, None, None).rating.is_none());
}
#[test]
fn get_many_returns_only_marked_hashes() {
let c = mem();
set(
&c,
&["a".into()],
&change_from_parts(Some(5), None, None, None),
)
.unwrap();
let map = get_many(&c, &["a".into(), "b".into()]).unwrap();
assert_eq!(map.get("a").and_then(|m| m.rating), Some(5));
assert!(!map.contains_key("b"), "unmarked hash must be absent");
}
#[test]
fn a_rating_change_is_a_change() {
let c = MarkChange {
rating: Some(Field::Set(4)),
..Default::default()
};
assert!(c.any());
}
#[test]
fn ensure_marks_table_is_idempotent() {
let c = mem();
ensure_marks_table(&c).unwrap();
let n: i64 = c
.query_row("SELECT COUNT(*) FROM marks", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 0);
}
#[test]
fn set_then_get_roundtrips_each_field() {
let c = mem();
set(
&c,
&["abc".into()],
&MarkChange {
rating: Some(Field::Set(4)),
pick: Some(Field::Set(Pick::Keep)),
label: Some(Field::Set("red".into())),
liked: Some(true),
},
)
.unwrap();
assert_eq!(
get(&c, "abc").unwrap(),
Marks {
rating: Some(4),
pick: Some(Pick::Keep),
label: Some("red".into()),
liked: true
}
);
}
#[test]
fn clearing_only_touches_named_fields() {
let c = mem();
set(
&c,
&["abc".into()],
&MarkChange {
rating: Some(Field::Set(5)),
liked: Some(true),
..Default::default()
},
)
.unwrap();
set(
&c,
&["abc".into()],
&MarkChange {
rating: Some(Field::Clear),
..Default::default()
},
)
.unwrap();
let m = get(&c, "abc").unwrap();
assert_eq!(m.rating, None);
assert!(m.liked);
}
#[test]
fn a_row_with_no_marks_left_is_deleted() {
let c = mem();
set(
&c,
&["abc".into()],
&MarkChange {
rating: Some(Field::Set(3)),
..Default::default()
},
)
.unwrap();
set(
&c,
&["abc".into()],
&MarkChange {
rating: Some(Field::Clear),
..Default::default()
},
)
.unwrap();
let n: i64 = c
.query_row("SELECT COUNT(*) FROM marks WHERE hash='abc'", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(n, 0, "an all-clear row must be removed");
}
#[test]
fn get_of_unmarked_is_default() {
let c = mem();
assert_eq!(get(&c, "nope").unwrap(), Marks::default());
}
#[test]
fn by_rating_is_at_least() {
let c = mem();
set(
&c,
&["a".into()],
&MarkChange {
rating: Some(Field::Set(5)),
..Default::default()
},
)
.unwrap();
set(
&c,
&["b".into()],
&MarkChange {
rating: Some(Field::Set(3)),
..Default::default()
},
)
.unwrap();
let hit = by_rating(&c, 4).unwrap();
assert!(
hit.contains("a") && !hit.contains("b"),
"--rating 4 means >= 4"
);
}
#[test]
fn by_pick_and_liked_are_exact() {
let c = mem();
set(
&c,
&["k".into()],
&MarkChange {
pick: Some(Field::Set(Pick::Keep)),
..Default::default()
},
)
.unwrap();
set(
&c,
&["r".into()],
&MarkChange {
pick: Some(Field::Set(Pick::Reject)),
..Default::default()
},
)
.unwrap();
set(
&c,
&["l".into()],
&MarkChange {
liked: Some(true),
..Default::default()
},
)
.unwrap();
assert_eq!(
by_pick(&c, Pick::Reject)
.unwrap()
.into_iter()
.collect::<Vec<_>>(),
vec!["r"]
);
assert_eq!(
by_liked(&c).unwrap().into_iter().collect::<Vec<_>>(),
vec!["l"]
);
}
#[test]
fn import_db_precedence_fills_gaps_only() {
let existing = Marks {
rating: Some(5),
..Default::default()
};
let c = import_change(&existing, Some(2), Some("Red".into()), XmpPrecedence::Db).unwrap();
assert!(c.rating.is_none(), "db rating kept");
assert!(matches!(c.label, Some(Field::Set(ref s)) if s == "Red"));
}
#[test]
fn import_file_precedence_overwrites() {
let existing = Marks {
rating: Some(5),
..Default::default()
};
let c = import_change(&existing, Some(2), None, XmpPrecedence::File).unwrap();
assert!(matches!(c.rating, Some(Field::Set(2))));
}
#[test]
fn import_nothing_to_do_is_none() {
let existing = Marks {
rating: Some(5),
label: Some("Red".into()),
..Default::default()
};
assert!(
import_change(&existing, Some(2), Some("Blue".into()), XmpPrecedence::Db).is_none()
);
}
}