Skip to main content

videre_core/
marks.rs

1//! Per-photo marks: rating, pick, colour label, like. One row per photo, keyed
2//! by content hash so a mark follows a photo across duplicates and moves.
3//!
4//! This module is the single implementation of set/get/query, and the only
5//! writer of the `marks` table. The `videre mark` command and the gallery API
6//! both call it; nothing else writes marks. Its predicates flow through
7//! `videre_core::selection` so `search`, `gallery` and MCP get them for free.
8
9use anyhow::Result;
10use rusqlite::Connection;
11use std::collections::{HashMap, HashSet};
12
13/// A photo's four marks. Absent means unset.
14#[derive(Debug, Clone, Default, PartialEq)]
15pub struct Marks {
16    /// Star rating 1..=5, or None when unrated.
17    pub rating: Option<i64>,
18    /// The culling decision, or None when undecided.
19    pub pick: Option<Pick>,
20    /// Colour label string, or None.
21    pub label: Option<String>,
22    /// Whether the photo is liked (a favourite).
23    pub liked: bool,
24}
25
26/// The culling decision. Keep and Reject are the two set states; "undecided" is
27/// `Option::None` at the field, so there is no third variant here.
28#[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/// One field of a partial update. `Set` writes a value, `Clear` removes it.
51#[derive(Debug, Clone)]
52pub enum Field<T> {
53    Set(T),
54    Clear,
55}
56
57/// A partial update. A field that is `None` is left untouched; `Some(Set)`
58/// writes it; `Some(Clear)` removes it. This is what lets
59/// `videre mark --rating 5` change only the rating.
60#[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    /// True if this change would touch at least one field. `videre mark` refuses
70    /// a no-op invocation on the strength of this.
71    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
76/// Build a `MarkChange` from the loosely-typed request shapes the CLI flags and
77/// the gallery's JSON body share: `rating` where 0 clears, `pick`/`label` where
78/// `"none"` clears, `liked` set directly. The one place these string forms map
79/// to the typed change, so the two callers cannot drift.
80pub 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, // "none"
92        }),
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
104/// Create the marks table if absent. Idempotent, safe on every open, called
105/// from `db::open_wal` the same way the `faces`/`people` tables are ensured.
106pub 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
120/// Read one photo's marks. An unmarked photo is `Marks::default()`.
121pub 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/// Counts of each kind of mark across the library, for `videre stats`.
140#[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
148/// Summarise the marks table. All zeroes when the table is absent, so a caller
149/// that predates marks still gets a valid struct. Returns `rusqlite::Result` to
150/// compose with `library_stats::compute`.
151pub 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
169/// Read marks for many hashes at once, for the gallery's file list. Only marked
170/// hashes appear in the map; an absent key means the photo is unmarked.
171pub 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
182/// Apply `change` to every hash. Fields not named are untouched; a `Clear`
183/// removes just that field; a row left with no marks is deleted so `marks`
184/// never fills with empty rows. Runs in one transaction. `hashes` is a user
185/// selection, not the whole library, so the row count is bounded.
186pub 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; // 0 means unrated
197            }
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
238// --- predicates, consumed through `RowSelection` -------------------------------
239
240/// Hashes with rating >= `min` (the "4+ stars" semantics).
241pub fn by_rating(conn: &Connection, min: i64) -> Result<HashSet<String>> {
242    hashes(conn, "SELECT hash FROM marks WHERE rating >= ?1", [min])
243}
244/// Hashes with exactly this pick state.
245pub 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}
252/// Hashes with exactly this colour label.
253pub fn by_label(conn: &Connection, label: &str) -> Result<HashSet<String>> {
254    hashes(conn, "SELECT hash FROM marks WHERE label = ?1", [label])
255}
256/// Hashes that are liked.
257pub 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// --- XMP import ---------------------------------------------------------------
268
269/// How a mark read from a file's XMP is reconciled with a mark already in the
270/// db, chosen by `--xmp` on scan/watch/import.
271#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
272pub enum XmpPrecedence {
273    /// The db wins; XMP only fills marks the db does not already have.
274    #[default]
275    Db,
276    /// The file wins; XMP replaces the db mark.
277    File,
278    /// The more recently changed wins. Reserved (DEBT:27); callers treat it as
279    /// `Db` with a warning until the timestamp signal is trustworthy.
280    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
294/// Given the marks already in the db and the rating/label read from XMP, produce
295/// the change to apply under `prec`, or None to leave the db untouched. Only
296/// rating and label are portable; pick and like have no XMP standard.
297pub fn import_change(
298    existing: &Marks,
299    xmp_rating: Option<i64>,
300    xmp_label: Option<String>,
301    prec: XmpPrecedence,
302) -> Option<MarkChange> {
303    // `Newest` is treated as `Db` here; the caller warns once. See DEBT:27.
304    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        // rating 0 clears, "none" clears pick/label, liked passes through.
345        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        // an absent field stays untouched
356        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        // db already has a rating: db wins, so no change for rating; label is a gap, so filled.
553        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}