mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
fix(core,fuse,gui,cli): read only the photos a refresh has not read
A timeline refresh resolved every photo's node to learn its name, media type, favourite tag, content hash and photo relation — 63 round-trips for a 12,445-photo library, minutes of work, repeated every time the timeline went stale sixty seconds later. None of that metadata changes on its own, and when it does change the event feed names the node it changed. `photos.resolved_at` (schema v31) records that a photo's node was read. The refresh resolves the photos without that mark: the ones it has never read, and the ones a remote event marked stale. An existing timeline is backfilled as resolved, so upgrading does not re-read the library once. Remote changes now name what they changed instead of asking for everything. `classify_photo_events` turns a `NodeUpdated` into `photos_mark_unresolved(uid)`, so a favourite toggled on a phone costs one node read rather than the whole library; a local toggle is claimed as our own echo, because the feed replays it and a `NodeUpdated` carries nothing that says what changed. A continuity loss and `refresh photos --full` drop every mark, which is the way back to a whole-library read. Along the way: `photos_replace` never kept a photo's name, so a photo the SDK could not decrypt was renamed to nothing on every refresh — and the tab split and the RAW+JPEG grouping are both derived from the name. It is kept now, and a row that *was* resolved is authoritative instead, so a photo unlinked from its main elsewhere finally loses its `main_uid`. `TIMELINE_ENRICH_CHUNK` is 150, matching the SDK's own `MAX_BATCH_COUNT`: a chunk of 200 was split there into a full request and a 50-uid tail.
This commit is contained in:
1 parent
376f3edb28
commit
6cc8f72bab
12 files changed
+634
-69
No files matched your search
@@ -592,7 +592,15 @@ pub enum RefreshScope {
|
||||
/// *started*, not when it has finished: re-reading a large library takes
|
||||
/// minutes, which is far longer than a control reply may take. Poll
|
||||
/// [`Request::PhotosRefreshStatus`] to know when the new timeline is there.
|
||||
Photos,
|
||||
///
|
||||
/// A refresh normally only reads the photos the daemon has never read and
|
||||
/// the ones a remote event marked changed. `full` drops that knowledge and
|
||||
/// reads every photo's node again — the repair for a timeline that somehow
|
||||
/// disagrees with the account, and minutes of work on a large library.
|
||||
Photos {
|
||||
#[serde(default)]
|
||||
full: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// A registered device in a [`Response::Devices`] listing.
|
||||
@@ -2077,6 +2085,16 @@ mod tests {
|
||||
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
||||
}
|
||||
|
||||
/// `full` defaults, so a front-end built against the older wire shape still
|
||||
/// asks for the ordinary refresh rather than failing to parse.
|
||||
#[test]
|
||||
fn a_photos_refresh_scope_defaults_to_not_full() {
|
||||
let scope: RefreshScope = serde_json::from_str(r#"{"Photos":{}}"#).unwrap();
|
||||
assert_eq!(scope, RefreshScope::Photos { full: false });
|
||||
let line = serde_json::to_string(&RefreshScope::Photos { full: true }).unwrap();
|
||||
assert_eq!(line, r#"{"Photos":{"full":true}}"#);
|
||||
}
|
||||
|
||||
/// A photos refresh is followed, not awaited, so the request that reports
|
||||
/// its progress is part of the wire contract.
|
||||
#[test]
|
||||
@@ -2262,7 +2280,10 @@ mod tests {
|
||||
scope: RefreshScope::Trash,
|
||||
},
|
||||
Request::Refresh {
|
||||
scope: RefreshScope::Photos,
|
||||
scope: RefreshScope::Photos { full: false },
|
||||
},
|
||||
Request::Refresh {
|
||||
scope: RefreshScope::Photos { full: true },
|
||||
},
|
||||
];
|
||||
for req in reqs {
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 30;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 31;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -254,6 +254,22 @@ impl Db {
|
||||
tx.execute_batch(MIGRATION_V30)?;
|
||||
}
|
||||
}
|
||||
if current < 31 {
|
||||
// Same guards as V26-V30.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'resolved_at'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_photos: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_photos && !has_column {
|
||||
tx.execute_batch(MIGRATION_V31)?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -918,3 +934,21 @@ ALTER TABLE photos ADD COLUMN main_uid TEXT;
|
||||
ALTER TABLE photos ADD COLUMN group_key TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_group ON photos(group_key);
|
||||
";
|
||||
|
||||
/// Schema v31: which photos a refresh still has to read from the server.
|
||||
///
|
||||
/// A refresh used to resolve every photo's node to learn its name, media type,
|
||||
/// favourite tag, content hash and photo relation — minutes of round-trips for a
|
||||
/// library of any size, repeated every time the timeline went stale. `resolved_at`
|
||||
/// records that the work was already done, so a refresh only reads the photos it
|
||||
/// has never read and the ones a remote event marked stale (`resolved_at = NULL`).
|
||||
///
|
||||
/// The backfill treats an existing timeline as resolved, so an upgrade does not
|
||||
/// re-read the whole library once. `WHERE name IS NOT NULL` keeps the photos
|
||||
/// whose earlier resolve had failed eligible: a nameless row is one nothing was
|
||||
/// ever learned about.
|
||||
const MIGRATION_V31: &str = "
|
||||
ALTER TABLE photos ADD COLUMN resolved_at INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_unresolved ON photos(resolved_at);
|
||||
UPDATE photos SET resolved_at = strftime('%s','now') WHERE name IS NOT NULL;
|
||||
";
|
||||
@@ -58,6 +58,11 @@ pub struct TimelineRow {
|
||||
/// The main photo this one is *related* to (a live-photo video, a burst
|
||||
/// sibling), when the server says it belongs to one.
|
||||
pub main_uid: Option<String>,
|
||||
/// When this refresh resolved the photo's node, if it did at all. `Some`
|
||||
/// makes every other field authoritative — including a `None` that means
|
||||
/// "the server no longer reports one" — while `None` means the refresh
|
||||
/// skipped this photo and whatever is stored must be kept.
|
||||
pub resolved_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl TimelineRow {
|
||||
@@ -74,12 +79,16 @@ impl TimelineRow {
|
||||
/// What a previous refresh learned about a photo and a new one must not lose.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Learned {
|
||||
name: Option<String>,
|
||||
ratio: Option<f64>,
|
||||
thumb_state: i64,
|
||||
media_type: Option<String>,
|
||||
favorite: bool,
|
||||
content_hash: Option<String>,
|
||||
main_uid: Option<String>,
|
||||
/// When this photo's node was last resolved, kept so a refresh that skips it
|
||||
/// does not make it look unresolved again.
|
||||
resolved_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// The local calendar day a capture time falls on, as a `YYYY-MM-DD` string.
|
||||
@@ -253,35 +262,39 @@ fn stored_photo(r: &rusqlite::Row<'_>) -> rusqlite::Result<StoredPhoto> {
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Replace the timeline wholesale. `favorite`, `media_type`, `content_hash`
|
||||
/// and `main_uid` are `None` when the refresh could not resolve that photo's
|
||||
/// node, in which case what is already stored is kept rather than silently
|
||||
/// cleared.
|
||||
/// Replace the timeline wholesale. A row whose `resolved_at` is `Some` was
|
||||
/// read from the server in this pass, so its fields are taken as they are —
|
||||
/// a `None` there means the server no longer reports that value. A row whose
|
||||
/// `resolved_at` is `None` was skipped or failed to resolve, and keeps
|
||||
/// whatever is already stored rather than having it silently cleared.
|
||||
pub fn photos_replace(&self, items: &[TimelineRow]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
// `media_type` is learned-and-kept like the ratio and thumb verdict: the
|
||||
// timeline DTO carries only the uid and capture time, so the daemon may
|
||||
// not know a photo's media type yet when it replaces the timeline. Keep
|
||||
// any previously learned value so the Photos/Videos/Raw split survives a
|
||||
// refresh instead of collapsing back to name-extension guesses. The photo
|
||||
// relation is kept for the same reason: losing it would break a group up
|
||||
// until the next successful resolve.
|
||||
// `name` and `media_type` are learned-and-kept like the ratio and thumb
|
||||
// verdict: the timeline DTO carries only the uid and capture time, so the
|
||||
// daemon may not know them when it replaces the timeline — because the
|
||||
// refresh skipped an already-resolved photo, or because resolving it
|
||||
// failed. Keep any previously learned value so the tab split and the
|
||||
// RAW+JPEG grouping survive a refresh instead of collapsing to nothing:
|
||||
// both are derived from the name. The photo relation is kept for the same
|
||||
// reason — losing it would break a group up until the next resolve.
|
||||
let learned: HashMap<String, Learned> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT uid, ratio, thumb_state, media_type, favorite, content_hash, main_uid \
|
||||
FROM photos",
|
||||
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
||||
main_uid, resolved_at FROM photos",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
Learned {
|
||||
ratio: r.get(1)?,
|
||||
thumb_state: r.get(2)?,
|
||||
media_type: r.get(3)?,
|
||||
favorite: r.get::<_, i64>(4)? != 0,
|
||||
content_hash: r.get(5)?,
|
||||
main_uid: r.get(6)?,
|
||||
name: r.get(1)?,
|
||||
ratio: r.get(2)?,
|
||||
thumb_state: r.get(3)?,
|
||||
media_type: r.get(4)?,
|
||||
favorite: r.get::<_, i64>(5)? != 0,
|
||||
content_hash: r.get(6)?,
|
||||
main_uid: r.get(7)?,
|
||||
resolved_at: r.get(8)?,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
@@ -296,15 +309,29 @@ impl Db {
|
||||
thumb_state: THUMB_UNKNOWN,
|
||||
..Learned::default()
|
||||
});
|
||||
carried.media_type = row.media_type.clone().or(carried.media_type);
|
||||
carried.favorite = row.favorite.unwrap_or(carried.favorite);
|
||||
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
|
||||
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
|
||||
if row.resolved_at.is_some() {
|
||||
// This pass read the node itself, so what it says is the
|
||||
// truth — including what it no longer says. A photo unlinked
|
||||
// from its main on another device loses its `main_uid` here,
|
||||
// which a carry could never express.
|
||||
carried.name = row.name.clone();
|
||||
carried.media_type = row.media_type.clone();
|
||||
carried.favorite = row.favorite.unwrap_or(false);
|
||||
carried.content_hash = row.content_hash.clone();
|
||||
carried.main_uid = row.main_uid.clone();
|
||||
} else {
|
||||
carried.name = row.name.clone().or(carried.name);
|
||||
carried.media_type = row.media_type.clone().or(carried.media_type);
|
||||
carried.favorite = row.favorite.unwrap_or(carried.favorite);
|
||||
carried.content_hash = row.content_hash.clone().or(carried.content_hash);
|
||||
carried.main_uid = row.main_uid.clone().or(carried.main_uid);
|
||||
}
|
||||
carried.resolved_at = row.resolved_at.or(carried.resolved_at);
|
||||
// The tab this photo lands in is derived here, once, so a page or
|
||||
// count query is a plain indexed `WHERE kind = ?` rather than a
|
||||
// reclassification of every row.
|
||||
let kind = crate::control::PhotoKind::classify(
|
||||
row.name.as_deref(),
|
||||
carried.name.as_deref(),
|
||||
carried.media_type.as_deref(),
|
||||
);
|
||||
(row, carried, kind)
|
||||
@@ -318,7 +345,7 @@ impl Db {
|
||||
kind: *kind,
|
||||
main_uid: carried.main_uid.clone(),
|
||||
day: capture_day(&tx, row.capture_time),
|
||||
stem: name_stem(row.name.as_deref()),
|
||||
stem: name_stem(carried.name.as_deref()),
|
||||
})
|
||||
.collect();
|
||||
let group_keys = group_photos(&entries);
|
||||
@@ -328,8 +355,8 @@ impl Db {
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO photos
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||
content_hash, main_uid, group_key)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
content_hash, main_uid, group_key, resolved_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
)?;
|
||||
for (seq, ((row, carried, kind), group_key)) in
|
||||
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||
@@ -337,7 +364,7 @@ impl Db {
|
||||
stmt.execute(params![
|
||||
row.uid,
|
||||
row.capture_time,
|
||||
row.name,
|
||||
carried.name,
|
||||
carried.ratio,
|
||||
carried.thumb_state,
|
||||
seq as i64,
|
||||
@@ -347,6 +374,7 @@ impl Db {
|
||||
carried.content_hash,
|
||||
carried.main_uid,
|
||||
group_key,
|
||||
carried.resolved_at,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -354,6 +382,50 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The uids whose node metadata is already resolved, so a refresh can skip
|
||||
/// reading their nodes again. Everything else — a photo never seen before, or
|
||||
/// one a remote event marked stale — is resolved. That is what keeps a
|
||||
/// 12k-photo library off 63 round-trips of `enumerate_nodes` every refresh.
|
||||
pub fn photos_resolved(&self) -> Result<Vec<String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("SELECT uid FROM photos WHERE resolved_at IS NOT NULL")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let mut uids = Vec::new();
|
||||
for row in rows {
|
||||
uids.push(row?);
|
||||
}
|
||||
Ok(uids)
|
||||
}
|
||||
|
||||
/// Mark photos as needing a fresh metadata resolve, and report how many rows
|
||||
/// that matched. This is how a remote change is recorded: the event feed says
|
||||
/// *which* node changed but never *what* changed about it, so the uid is
|
||||
/// written down and the next refresh reads the node.
|
||||
pub fn photos_mark_unresolved(&self, uids: &[String]) -> Result<usize> {
|
||||
if uids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let mut marked = 0;
|
||||
{
|
||||
let mut stmt = tx.prepare("UPDATE photos SET resolved_at = NULL WHERE uid = ?1")?;
|
||||
for uid in uids {
|
||||
marked += stmt.execute(params![uid])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(marked)
|
||||
}
|
||||
|
||||
/// The same for the whole timeline: every photo is resolved again on the next
|
||||
/// refresh. For a continuity loss, where the daemon cannot know what it
|
||||
/// missed, and for an explicit `refresh photos --full`.
|
||||
pub fn photos_mark_all_unresolved(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
Ok(conn.execute("UPDATE photos SET resolved_at = NULL", [])?)
|
||||
}
|
||||
|
||||
/// Forget photos that have been trashed, so the gallery does not show them
|
||||
/// until the next timeline refresh would have caught up.
|
||||
///
|
||||
|
||||
@@ -4217,3 +4217,215 @@ fn migration_v30_leaves_a_v29_timeline_intact() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||
}
|
||||
|
||||
/// A refresh that skips an already-resolved photo sends a row carrying only the
|
||||
/// uid and capture time. The name must survive that, because the tab split and
|
||||
/// the grouping are both derived from it.
|
||||
#[test]
|
||||
fn photos_replace_keeps_a_skipped_photos_name() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
TimelineRow {
|
||||
name: Some("IMG_1234.JPG".into()),
|
||||
media_type: Some("image/jpeg".into()),
|
||||
resolved_at: Some(1),
|
||||
..TimelineRow::new("jpeg", 300)
|
||||
},
|
||||
TimelineRow {
|
||||
name: Some("IMG_1234.CR2".into()),
|
||||
media_type: Some("application/octet-stream".into()),
|
||||
resolved_at: Some(1),
|
||||
..TimelineRow::new("raw", 300)
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
assert_eq!(page.len(), 1, "one shot, two files");
|
||||
assert!(page[0].has_raw);
|
||||
|
||||
// The next refresh resolves neither, because both are already resolved.
|
||||
db.photos_replace(&[TimelineRow::new("jpeg", 300), TimelineRow::new("raw", 300)])
|
||||
.unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
assert_eq!(page.len(), 1, "the group survives a skipped refresh");
|
||||
assert_eq!(page[0].name.as_deref(), Some("IMG_1234.JPG"));
|
||||
assert!(page[0].has_raw, "the raw file is still a raw file");
|
||||
assert_eq!(page[0].group_size, 2);
|
||||
}
|
||||
|
||||
/// Only the photos a refresh actually read are marked resolved; the rest stay
|
||||
/// eligible, so the next refresh tries them again.
|
||||
#[test]
|
||||
fn photos_replace_marks_only_the_rows_it_resolved() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
TimelineRow {
|
||||
name: Some("ok.jpg".into()),
|
||||
resolved_at: Some(1),
|
||||
..TimelineRow::new("ok", 300)
|
||||
},
|
||||
// The chunk holding this one failed.
|
||||
TimelineRow::new("failed", 200),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(db.photos_resolved().unwrap(), ["ok"]);
|
||||
|
||||
// A resolved photo stays resolved across a refresh that skips it.
|
||||
db.photos_replace(&[
|
||||
TimelineRow::new("ok", 300),
|
||||
TimelineRow {
|
||||
name: Some("late.jpg".into()),
|
||||
resolved_at: Some(2),
|
||||
..TimelineRow::new("failed", 200)
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
let mut resolved = db.photos_resolved().unwrap();
|
||||
resolved.sort();
|
||||
assert_eq!(resolved, ["failed", "ok"]);
|
||||
}
|
||||
|
||||
/// What a refresh read is the truth, including what it no longer reports: a
|
||||
/// photo unlinked from its main on another device leaves the group.
|
||||
#[test]
|
||||
fn a_resolved_replace_clears_a_relation_the_server_dropped() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
TimelineRow {
|
||||
name: Some("IMG_0001.HEIC".into()),
|
||||
resolved_at: Some(1),
|
||||
..TimelineRow::new("main", 300)
|
||||
},
|
||||
TimelineRow {
|
||||
name: Some("clip.mov".into()),
|
||||
main_uid: Some("main".into()),
|
||||
resolved_at: Some(1),
|
||||
..TimelineRow::new("related", 299)
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(db.photos_group("related").unwrap().len(), 2);
|
||||
|
||||
// Both are read again and the server no longer reports the relation.
|
||||
db.photos_replace(&[
|
||||
TimelineRow {
|
||||
name: Some("IMG_0001.HEIC".into()),
|
||||
resolved_at: Some(2),
|
||||
..TimelineRow::new("main", 300)
|
||||
},
|
||||
TimelineRow {
|
||||
name: Some("clip.mov".into()),
|
||||
resolved_at: Some(2),
|
||||
..TimelineRow::new("related", 299)
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.photos_group("related").unwrap().len(),
|
||||
1,
|
||||
"an unlinked photo stands on its own"
|
||||
);
|
||||
}
|
||||
|
||||
/// A refresh that did *not* read the nodes must not break the group up, which is
|
||||
/// the older behaviour and still the one a failed resolve gets.
|
||||
#[test]
|
||||
fn an_unresolved_replace_keeps_a_relation() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
TimelineRow {
|
||||
name: Some("IMG_0001.HEIC".into()),
|
||||
resolved_at: Some(1),
|
||||
..TimelineRow::new("main", 300)
|
||||
},
|
||||
TimelineRow {
|
||||
name: Some("clip.mov".into()),
|
||||
main_uid: Some("main".into()),
|
||||
resolved_at: Some(1),
|
||||
..TimelineRow::new("related", 299)
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
db.photos_replace(&[
|
||||
TimelineRow::new("main", 300),
|
||||
TimelineRow::new("related", 299),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(db.photos_group("related").unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A remote event names the photo it changed, and that is all it takes to have
|
||||
/// the next refresh read that one node again.
|
||||
#[test]
|
||||
fn marking_a_photo_unresolved_makes_it_eligible_again() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
TimelineRow {
|
||||
name: Some("a.jpg".into()),
|
||||
resolved_at: Some(1),
|
||||
..TimelineRow::new("a", 300)
|
||||
},
|
||||
TimelineRow {
|
||||
name: Some("b.jpg".into()),
|
||||
resolved_at: Some(1),
|
||||
..TimelineRow::new("b", 200)
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(db.photos_mark_unresolved(&["a".to_string()]).unwrap(), 1);
|
||||
assert_eq!(db.photos_resolved().unwrap(), ["b"]);
|
||||
// A uid the timeline does not hold changes nothing.
|
||||
assert_eq!(db.photos_mark_unresolved(&["gone".to_string()]).unwrap(), 0);
|
||||
|
||||
assert_eq!(db.photos_mark_all_unresolved().unwrap(), 2);
|
||||
assert!(db.photos_resolved().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// An existing timeline opens as resolved, so upgrading does not re-read the
|
||||
/// whole library once — except for the photos nothing was ever learned about.
|
||||
#[test]
|
||||
fn migration_v31_treats_an_existing_timeline_as_resolved() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pdfs-db-v30-fixture-{}-{}.db",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
{
|
||||
let db = Db::open(&path).unwrap();
|
||||
db.photos_replace(&[
|
||||
TimelineRow {
|
||||
name: Some("IMG_1234.JPG".into()),
|
||||
resolved_at: Some(1),
|
||||
..TimelineRow::new("named", 300)
|
||||
},
|
||||
// A photo whose resolve had failed: nothing was ever learned.
|
||||
TimelineRow::new("nameless", 200),
|
||||
])
|
||||
.unwrap();
|
||||
}
|
||||
{
|
||||
// Put the file back in the state a released V30 database was in.
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"DROP INDEX idx_photos_unresolved;
|
||||
ALTER TABLE photos DROP COLUMN resolved_at;
|
||||
UPDATE sync_state SET value = '30' WHERE key = 'schema_version';",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let db = Db::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
db.photos_resolved().unwrap(),
|
||||
["named"],
|
||||
"the stored timeline is trusted, a nameless row is not"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||
}
|
||||
Reference in new issue
Block a user