1use anyhow::Result;
10use rusqlite::Connection;
11use std::collections::{HashMap, HashSet};
12
13#[derive(Debug, Clone, Default, PartialEq)]
15pub struct Marks {
16 pub rating: Option<i64>,
18 pub pick: Option<Pick>,
20 pub label: Option<String>,
22 pub liked: bool,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Pick {
30 Keep,
31 Reject,
32}
33
34impl Pick {
35 pub fn as_bool(self) -> i64 {
36 match self {
37 Pick::Keep => 1,
38 Pick::Reject => 0,
39 }
40 }
41 pub fn from_bool(v: i64) -> Pick {
42 if v == 0 {
43 Pick::Reject
44 } else {
45 Pick::Keep
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
52pub enum Field<T> {
53 Set(T),
54 Clear,
55}
56
57#[derive(Debug, Clone, Default)]
61pub struct MarkChange {
62 pub rating: Option<Field<i64>>,
63 pub pick: Option<Field<Pick>>,
64 pub label: Option<Field<String>>,
65 pub liked: Option<bool>,
66}
67
68impl MarkChange {
69 pub fn any(&self) -> bool {
72 self.rating.is_some() || self.pick.is_some() || self.label.is_some() || self.liked.is_some()
73 }
74}
75
76pub fn change_from_parts(
81 rating: Option<i64>,
82 pick: Option<&str>,
83 label: Option<&str>,
84 liked: Option<bool>,
85) -> MarkChange {
86 MarkChange {
87 rating: rating.map(|r| if r == 0 { Field::Clear } else { Field::Set(r) }),
88 pick: pick.map(|p| match p {
89 "keep" => Field::Set(Pick::Keep),
90 "reject" => Field::Set(Pick::Reject),
91 _ => Field::Clear, }),
93 label: label.map(|l| {
94 if l == "none" {
95 Field::Clear
96 } else {
97 Field::Set(l.to_string())
98 }
99 }),
100 liked,
101 }
102}
103
104pub fn ensure_marks_table(conn: &Connection) -> Result<()> {
107 conn.execute_batch(
108 "CREATE TABLE IF NOT EXISTS marks (
109 hash TEXT PRIMARY KEY,
110 rating INTEGER,
111 pick INTEGER,
112 label TEXT,
113 liked INTEGER NOT NULL DEFAULT 0,
114 updated_at TEXT NOT NULL
115 );",
116 )?;
117 Ok(())
118}
119
120pub fn get(conn: &Connection, hash: &str) -> Result<Marks> {
122 let row = conn
123 .query_row(
124 "SELECT rating, pick, label, liked FROM marks WHERE hash = ?1",
125 [hash],
126 |r| {
127 Ok(Marks {
128 rating: r.get::<_, Option<i64>>(0)?,
129 pick: r.get::<_, Option<i64>>(1)?.map(Pick::from_bool),
130 label: r.get::<_, Option<String>>(2)?,
131 liked: r.get::<_, i64>(3)? != 0,
132 })
133 },
134 )
135 .ok();
136 Ok(row.unwrap_or_default())
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize)]
141pub struct MarksSummary {
142 pub rated: i64,
143 pub picked: i64,
144 pub labelled: i64,
145 pub liked: i64,
146}
147
148pub fn summary(conn: &Connection) -> rusqlite::Result<MarksSummary> {
152 if !crate::db::table_exists(conn, "marks")? {
153 return Ok(MarksSummary::default());
154 }
155 Ok(conn.query_row(
156 "SELECT COUNT(rating), COUNT(pick), COUNT(label), COALESCE(SUM(liked), 0) FROM marks",
157 [],
158 |r| {
159 Ok(MarksSummary {
160 rated: r.get(0)?,
161 picked: r.get(1)?,
162 labelled: r.get(2)?,
163 liked: r.get(3)?,
164 })
165 },
166 )?)
167}
168
169pub fn get_many(conn: &Connection, hashes: &[String]) -> Result<HashMap<String, Marks>> {
172 let mut out = HashMap::new();
173 for h in hashes {
174 let m = get(conn, h)?;
175 if m != Marks::default() {
176 out.insert(h.clone(), m);
177 }
178 }
179 Ok(out)
180}
181
182pub fn set(conn: &Connection, hashes: &[String], change: &MarkChange) -> Result<()> {
187 let tx = conn.unchecked_transaction()?;
188 for h in hashes {
189 let mut m = get(&tx, h)?;
190 if let Some(f) = &change.rating {
191 m.rating = match f {
192 Field::Set(v) => Some((*v).clamp(0, 5)),
193 Field::Clear => None,
194 };
195 if m.rating == Some(0) {
196 m.rating = None; }
198 }
199 if let Some(f) = &change.pick {
200 m.pick = match f {
201 Field::Set(p) => Some(*p),
202 Field::Clear => None,
203 };
204 }
205 if let Some(f) = &change.label {
206 m.label = match f {
207 Field::Set(s) => Some(s.clone()),
208 Field::Clear => None,
209 };
210 }
211 if let Some(v) = change.liked {
212 m.liked = v;
213 }
214
215 let empty = m.rating.is_none() && m.pick.is_none() && m.label.is_none() && !m.liked;
216 if empty {
217 tx.execute("DELETE FROM marks WHERE hash = ?1", [h])?;
218 } else {
219 tx.execute(
220 "INSERT INTO marks (hash, rating, pick, label, liked, updated_at)
221 VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'))
222 ON CONFLICT(hash) DO UPDATE SET
223 rating = ?2, pick = ?3, label = ?4, liked = ?5, updated_at = datetime('now')",
224 rusqlite::params![
225 h,
226 m.rating,
227 m.pick.map(Pick::as_bool),
228 m.label,
229 m.liked as i64,
230 ],
231 )?;
232 }
233 }
234 tx.commit()?;
235 Ok(())
236}
237
238pub fn by_rating(conn: &Connection, min: i64) -> Result<HashSet<String>> {
242 hashes(conn, "SELECT hash FROM marks WHERE rating >= ?1", [min])
243}
244pub fn by_pick(conn: &Connection, pick: Pick) -> Result<HashSet<String>> {
246 hashes(
247 conn,
248 "SELECT hash FROM marks WHERE pick = ?1",
249 [pick.as_bool()],
250 )
251}
252pub fn by_label(conn: &Connection, label: &str) -> Result<HashSet<String>> {
254 hashes(conn, "SELECT hash FROM marks WHERE label = ?1", [label])
255}
256pub fn by_liked(conn: &Connection) -> Result<HashSet<String>> {
258 hashes(conn, "SELECT hash FROM marks WHERE liked = 1", [])
259}
260
261fn hashes<P: rusqlite::Params>(conn: &Connection, sql: &str, p: P) -> Result<HashSet<String>> {
262 let mut stmt = conn.prepare(sql)?;
263 let rows = stmt.query_map(p, |r| r.get::<_, String>(0))?;
264 Ok(rows.collect::<rusqlite::Result<HashSet<String>>>()?)
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
272pub enum XmpPrecedence {
273 #[default]
275 Db,
276 File,
278 Newest,
281}
282
283impl XmpPrecedence {
284 pub fn parse(s: &str) -> Result<Self> {
285 match s {
286 "db" => Ok(Self::Db),
287 "file" => Ok(Self::File),
288 "newest" => Ok(Self::Newest),
289 other => anyhow::bail!("unknown --xmp value {other:?}; expected db, file, or newest"),
290 }
291 }
292}
293
294pub fn import_change(
298 existing: &Marks,
299 xmp_rating: Option<i64>,
300 xmp_label: Option<String>,
301 prec: XmpPrecedence,
302) -> Option<MarkChange> {
303 let file_wins = matches!(prec, XmpPrecedence::File);
305 let want = |db_has: bool| file_wins || !db_has;
306
307 let mut c = MarkChange::default();
308 if let Some(r) = xmp_rating {
309 if want(existing.rating.is_some()) {
310 c.rating = Some(Field::Set(r));
311 }
312 }
313 if let Some(l) = xmp_label {
314 if want(existing.label.is_some()) {
315 c.label = Some(Field::Set(l));
316 }
317 }
318 if c.any() {
319 Some(c)
320 } else {
321 None
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn mem() -> Connection {
330 let c = Connection::open_in_memory().unwrap();
331 c.execute_batch("CREATE TABLE file_hashes (path TEXT PRIMARY KEY, hash TEXT NOT NULL);")
332 .unwrap();
333 ensure_marks_table(&c).unwrap();
334 c
335 }
336
337 #[test]
338 fn empty_change_touches_nothing() {
339 assert!(!MarkChange::default().any());
340 }
341
342 #[test]
343 fn change_from_parts_maps_the_request_shapes() {
344 let c = change_from_parts(Some(0), Some("none"), Some("none"), Some(false));
346 assert!(matches!(c.rating, Some(Field::Clear)));
347 assert!(matches!(c.pick, Some(Field::Clear)));
348 assert!(matches!(c.label, Some(Field::Clear)));
349 assert_eq!(c.liked, Some(false));
350 let c = change_from_parts(Some(4), Some("reject"), Some("Red"), None);
351 assert!(matches!(c.rating, Some(Field::Set(4))));
352 assert!(matches!(c.pick, Some(Field::Set(Pick::Reject))));
353 assert!(matches!(c.label, Some(Field::Set(ref s)) if s == "Red"));
354 assert_eq!(c.liked, None);
355 assert!(change_from_parts(None, None, None, None).rating.is_none());
357 }
358
359 #[test]
360 fn get_many_returns_only_marked_hashes() {
361 let c = mem();
362 set(
363 &c,
364 &["a".into()],
365 &change_from_parts(Some(5), None, None, None),
366 )
367 .unwrap();
368 let map = get_many(&c, &["a".into(), "b".into()]).unwrap();
369 assert_eq!(map.get("a").and_then(|m| m.rating), Some(5));
370 assert!(!map.contains_key("b"), "unmarked hash must be absent");
371 }
372
373 #[test]
374 fn a_rating_change_is_a_change() {
375 let c = MarkChange {
376 rating: Some(Field::Set(4)),
377 ..Default::default()
378 };
379 assert!(c.any());
380 }
381
382 #[test]
383 fn ensure_marks_table_is_idempotent() {
384 let c = mem();
385 ensure_marks_table(&c).unwrap();
386 let n: i64 = c
387 .query_row("SELECT COUNT(*) FROM marks", [], |r| r.get(0))
388 .unwrap();
389 assert_eq!(n, 0);
390 }
391
392 #[test]
393 fn set_then_get_roundtrips_each_field() {
394 let c = mem();
395 set(
396 &c,
397 &["abc".into()],
398 &MarkChange {
399 rating: Some(Field::Set(4)),
400 pick: Some(Field::Set(Pick::Keep)),
401 label: Some(Field::Set("red".into())),
402 liked: Some(true),
403 },
404 )
405 .unwrap();
406 assert_eq!(
407 get(&c, "abc").unwrap(),
408 Marks {
409 rating: Some(4),
410 pick: Some(Pick::Keep),
411 label: Some("red".into()),
412 liked: true
413 }
414 );
415 }
416
417 #[test]
418 fn clearing_only_touches_named_fields() {
419 let c = mem();
420 set(
421 &c,
422 &["abc".into()],
423 &MarkChange {
424 rating: Some(Field::Set(5)),
425 liked: Some(true),
426 ..Default::default()
427 },
428 )
429 .unwrap();
430 set(
431 &c,
432 &["abc".into()],
433 &MarkChange {
434 rating: Some(Field::Clear),
435 ..Default::default()
436 },
437 )
438 .unwrap();
439 let m = get(&c, "abc").unwrap();
440 assert_eq!(m.rating, None);
441 assert!(m.liked);
442 }
443
444 #[test]
445 fn a_row_with_no_marks_left_is_deleted() {
446 let c = mem();
447 set(
448 &c,
449 &["abc".into()],
450 &MarkChange {
451 rating: Some(Field::Set(3)),
452 ..Default::default()
453 },
454 )
455 .unwrap();
456 set(
457 &c,
458 &["abc".into()],
459 &MarkChange {
460 rating: Some(Field::Clear),
461 ..Default::default()
462 },
463 )
464 .unwrap();
465 let n: i64 = c
466 .query_row("SELECT COUNT(*) FROM marks WHERE hash='abc'", [], |r| {
467 r.get(0)
468 })
469 .unwrap();
470 assert_eq!(n, 0, "an all-clear row must be removed");
471 }
472
473 #[test]
474 fn get_of_unmarked_is_default() {
475 let c = mem();
476 assert_eq!(get(&c, "nope").unwrap(), Marks::default());
477 }
478
479 #[test]
480 fn by_rating_is_at_least() {
481 let c = mem();
482 set(
483 &c,
484 &["a".into()],
485 &MarkChange {
486 rating: Some(Field::Set(5)),
487 ..Default::default()
488 },
489 )
490 .unwrap();
491 set(
492 &c,
493 &["b".into()],
494 &MarkChange {
495 rating: Some(Field::Set(3)),
496 ..Default::default()
497 },
498 )
499 .unwrap();
500 let hit = by_rating(&c, 4).unwrap();
501 assert!(
502 hit.contains("a") && !hit.contains("b"),
503 "--rating 4 means >= 4"
504 );
505 }
506
507 #[test]
508 fn by_pick_and_liked_are_exact() {
509 let c = mem();
510 set(
511 &c,
512 &["k".into()],
513 &MarkChange {
514 pick: Some(Field::Set(Pick::Keep)),
515 ..Default::default()
516 },
517 )
518 .unwrap();
519 set(
520 &c,
521 &["r".into()],
522 &MarkChange {
523 pick: Some(Field::Set(Pick::Reject)),
524 ..Default::default()
525 },
526 )
527 .unwrap();
528 set(
529 &c,
530 &["l".into()],
531 &MarkChange {
532 liked: Some(true),
533 ..Default::default()
534 },
535 )
536 .unwrap();
537 assert_eq!(
538 by_pick(&c, Pick::Reject)
539 .unwrap()
540 .into_iter()
541 .collect::<Vec<_>>(),
542 vec!["r"]
543 );
544 assert_eq!(
545 by_liked(&c).unwrap().into_iter().collect::<Vec<_>>(),
546 vec!["l"]
547 );
548 }
549
550 #[test]
551 fn import_db_precedence_fills_gaps_only() {
552 let existing = Marks {
554 rating: Some(5),
555 ..Default::default()
556 };
557 let c = import_change(&existing, Some(2), Some("Red".into()), XmpPrecedence::Db).unwrap();
558 assert!(c.rating.is_none(), "db rating kept");
559 assert!(matches!(c.label, Some(Field::Set(ref s)) if s == "Red"));
560 }
561
562 #[test]
563 fn import_file_precedence_overwrites() {
564 let existing = Marks {
565 rating: Some(5),
566 ..Default::default()
567 };
568 let c = import_change(&existing, Some(2), None, XmpPrecedence::File).unwrap();
569 assert!(matches!(c.rating, Some(Field::Set(2))));
570 }
571
572 #[test]
573 fn import_nothing_to_do_is_none() {
574 let existing = Marks {
575 rating: Some(5),
576 label: Some("Red".into()),
577 ..Default::default()
578 };
579 assert!(
580 import_change(&existing, Some(2), Some("Blue".into()), XmpPrecedence::Db).is_none()
581 );
582 }
583}