mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
persistent entity cache, streaming batche
This commit is contained in:
1 parent
2336ad9e87
commit
a5e03ad8da
33 files changed
+1936
-65
No files matched your search
@@ -178,6 +178,10 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
// Album rows have no favourite state of their own: an album
|
||||
// photo on someone else's volume is not in our timeline, and
|
||||
// favouriting one is not supported yet anyway.
|
||||
favorite: false,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
@@ -218,6 +222,10 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
// Album rows have no favourite state of their own: an album
|
||||
// photo on someone else's volume is not in our timeline, and
|
||||
// favouriting one is not supported yet anyway.
|
||||
favorite: false,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
|
||||
@@ -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 = 19;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 20;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -98,6 +98,9 @@ impl Db {
|
||||
if current < 19 {
|
||||
tx.execute_batch(MIGRATION_V19)?;
|
||||
}
|
||||
if current < 20 {
|
||||
tx.execute_batch(MIGRATION_V20)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -511,3 +514,16 @@ CREATE TABLE album_photos (
|
||||
CREATE INDEX idx_album_photos_seq ON album_photos(album_uid, seq);
|
||||
CREATE INDEX idx_album_photos_uid ON album_photos(uid);
|
||||
";
|
||||
|
||||
/// V20: whether a photo is a favourite.
|
||||
///
|
||||
/// A column rather than a tag table: `Favorite` is the only Proton photo tag a
|
||||
/// user sets by hand (the rest — Video, Screenshot, Selfie, Raw… — are the
|
||||
/// server's own classification, and the Photos page already derives its tabs
|
||||
/// from the media type). Defaulting to 0 means an existing timeline stays
|
||||
/// intact and each row learns its real value on the next refresh, the same way
|
||||
/// `media_type` did.
|
||||
const MIGRATION_V20: &str = "
|
||||
ALTER TABLE photos ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos(favorite, seq);
|
||||
";
|
||||
@@ -49,7 +49,7 @@ pub use ops::{
|
||||
AttachedBlob, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PARK_UNTIL,
|
||||
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
pub use trash::StoredTrash;
|
||||
|
||||
|
||||
@@ -32,13 +32,21 @@ pub struct StoredPhoto {
|
||||
/// Which Photos-page tab this entry belongs to, derived from its name and
|
||||
/// media type when the timeline was last replaced.
|
||||
pub kind: crate::control::PhotoKind,
|
||||
/// Whether the photo carries Proton's `Favorite` tag.
|
||||
pub favorite: bool,
|
||||
}
|
||||
|
||||
/// One row of a timeline replacement: `(uid, capture_time, name, media_type,
|
||||
/// favorite)`. The two trailing options are "what this refresh learned" — a
|
||||
/// `None` keeps whatever is already stored rather than clearing it.
|
||||
pub type TimelineRow = (String, i64, Option<String>, Option<String>, Option<bool>);
|
||||
|
||||
impl Db {
|
||||
pub fn photos_replace(
|
||||
&self,
|
||||
items: &[(String, i64, Option<String>, Option<String>)],
|
||||
) -> Result<()> {
|
||||
/// Replace the timeline wholesale. Each item is
|
||||
/// `(uid, capture_time, name, media_type, favorite)`; `favorite` is `None`
|
||||
/// when the refresh could not resolve that photo's node, in which case the
|
||||
/// flag already stored is kept rather than 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
|
||||
@@ -46,10 +54,14 @@ impl Db {
|
||||
// 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.
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>)> = {
|
||||
let mut stmt = tx.prepare("SELECT uid, ratio, thumb_state, media_type FROM photos")?;
|
||||
let learned: HashMap<String, (Option<f64>, i64, Option<String>, bool)> = {
|
||||
let mut stmt =
|
||||
tx.prepare("SELECT uid, ratio, thumb_state, media_type, favorite FROM photos")?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((r.get::<_, String>(0)?, (r.get(1)?, r.get(2)?, r.get(3)?)))
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
(r.get(1)?, r.get(2)?, r.get(3)?, r.get::<_, i64>(4)? != 0),
|
||||
))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
@@ -58,16 +70,16 @@ impl Db {
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO photos
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)?;
|
||||
for (seq, (uid, capture_time, name, media_type)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media) =
|
||||
learned
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or((None, THUMB_UNKNOWN, None));
|
||||
for (seq, (uid, capture_time, name, media_type, favorite)) in items.iter().enumerate() {
|
||||
let (ratio, thumb_state, learned_media, learned_favorite) = learned
|
||||
.get(uid)
|
||||
.cloned()
|
||||
.unwrap_or((None, THUMB_UNKNOWN, None, false));
|
||||
let media_type = media_type.clone().or(learned_media);
|
||||
let favorite = favorite.unwrap_or(learned_favorite);
|
||||
// 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.
|
||||
@@ -82,6 +94,7 @@ impl Db {
|
||||
seq as i64,
|
||||
media_type,
|
||||
kind.as_i64(),
|
||||
favorite as i64,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -93,18 +106,21 @@ impl Db {
|
||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||
/// date scrubber's jump. `offset` is relative to whatever the filters leave.
|
||||
/// `favorites` restricts the page to favourited photos.
|
||||
pub fn photos_page(
|
||||
&self,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
range: Option<(i64, i64)>,
|
||||
favorites: bool,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.conn.lock();
|
||||
// Built up so any combination of the optional filters is one indexed
|
||||
// query rather than a statement per case.
|
||||
let mut sql =
|
||||
String::from("SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos");
|
||||
let mut sql = String::from(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos",
|
||||
);
|
||||
let mut binds: Vec<i64> = Vec::new();
|
||||
let mut conds: Vec<String> = Vec::new();
|
||||
if let Some(k) = kind {
|
||||
@@ -117,6 +133,9 @@ impl Db {
|
||||
binds.push(to);
|
||||
conds.push(format!("capture_time < ?{}", binds.len()));
|
||||
}
|
||||
if favorites {
|
||||
conds.push("favorite = 1".to_string());
|
||||
}
|
||||
if !conds.is_empty() {
|
||||
sql.push_str(" WHERE ");
|
||||
sql.push_str(&conds.join(" AND "));
|
||||
@@ -139,6 +158,7 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
favorite: r.get::<_, i64>(6)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?;
|
||||
@@ -206,7 +226,7 @@ impl Db {
|
||||
let conn = self.conn.lock();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM photos
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
|
||||
WHERE uid IN ({placeholders})"
|
||||
))?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(uids), |r| {
|
||||
@@ -217,6 +237,7 @@ impl Db {
|
||||
ratio: r.get(3)?,
|
||||
thumb_state: r.get(4)?,
|
||||
kind: crate::control::PhotoKind::from_i64(r.get(5)?),
|
||||
favorite: r.get::<_, i64>(6)? != 0,
|
||||
})
|
||||
})?;
|
||||
let mut photos = Vec::new();
|
||||
@@ -226,6 +247,18 @@ impl Db {
|
||||
Ok(photos)
|
||||
}
|
||||
|
||||
/// Record a photo's favourite flag locally, after the server accepted the
|
||||
/// change. A uid the timeline does not hold is a no-op — an album photo on
|
||||
/// someone else's volume is never in our own `photos` table.
|
||||
pub fn photos_set_favorite(&self, uid: &str, favorite: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE photos SET favorite = ?2 WHERE uid = ?1",
|
||||
params![uid, favorite as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record what a thumbnail attempt learned: whether the photo now has one
|
||||
/// ([`THUMB_HAVE`] / [`THUMB_NONE`]), and its aspect ratio if the pixels were
|
||||
/// seen. A `None` ratio leaves any previously learned one alone.
|
||||
|
||||
@@ -113,9 +113,9 @@ fn node_from(parent: serde_json::Value, link: &str, name: &str, kind: serde_json
|
||||
fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
("p1".into(), 300, None, Some("video/mp4".into())),
|
||||
("p2".into(), 200, None, None),
|
||||
("p3".into(), 100, None, None),
|
||||
("p1".into(), 300, None, Some("video/mp4".into()), Some(true)),
|
||||
("p2".into(), 200, None, None, None),
|
||||
("p3".into(), 100, None, None, None),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
@@ -126,13 +126,13 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
// The next refresh brings a new photo, keeps p1 and p2, and loses p3.
|
||||
// p1's media type arrives as `None` this time and must not be forgotten.
|
||||
db.photos_replace(&[
|
||||
("p0".into(), 400, Some("new.jpg".into()), None),
|
||||
("p1".into(), 300, None, None),
|
||||
("p2".into(), 200, None, None),
|
||||
("p0".into(), 400, Some("new.jpg".into()), None, Some(false)),
|
||||
("p1".into(), 300, None, None, None),
|
||||
("p2".into(), 200, None, None, None),
|
||||
])
|
||||
.unwrap();
|
||||
|
||||
let page = db.photos_page(0, 10, None, None).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p0", "p1", "p2"],
|
||||
@@ -155,7 +155,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
// The counts break down by tab, and a filtered page returns only its tab.
|
||||
assert_eq!(db.photos_counts().unwrap(), (2, 1, 0));
|
||||
let videos = db
|
||||
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None)
|
||||
.photos_page(0, 10, Some(crate::control::PhotoKind::Video), None, false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
videos.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
@@ -167,7 +167,9 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
|
||||
// A date-range page keeps only the window's photos: [150, 350) is p1+p2,
|
||||
// not p0 at 400. Combined with a kind filter both conditions apply.
|
||||
let ranged = db.photos_page(0, 10, None, Some((150, 350))).unwrap();
|
||||
let ranged = db
|
||||
.photos_page(0, 10, None, Some((150, 350)), false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ranged.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p1", "p2"]
|
||||
@@ -178,6 +180,7 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
10,
|
||||
Some(crate::control::PhotoKind::Video),
|
||||
Some((150, 350)),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -193,20 +196,52 @@ fn photos_replace_keeps_what_was_learned_and_drops_what_left() {
|
||||
assert_eq!(months[0].2, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_are_remembered_across_refreshes_and_filter_a_page() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
("p1".into(), 300, None, None, Some(true)),
|
||||
("p2".into(), 200, None, None, Some(false)),
|
||||
])
|
||||
.unwrap();
|
||||
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
||||
assert_eq!(
|
||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p1"],
|
||||
"only the favourited photo is in a favourites page"
|
||||
);
|
||||
|
||||
// A local toggle survives a refresh that could not resolve the photo's node
|
||||
// (`None`), and loses to one that could.
|
||||
db.photos_set_favorite("p2", true).unwrap();
|
||||
db.photos_replace(&[
|
||||
("p1".into(), 300, None, None, Some(false)),
|
||||
("p2".into(), 200, None, None, None),
|
||||
])
|
||||
.unwrap();
|
||||
let favorites = db.photos_page(0, 10, None, None, true).unwrap();
|
||||
assert_eq!(
|
||||
favorites.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p2"],
|
||||
"the server's answer wins where it has one; the local flag holds where it doesn't"
|
||||
);
|
||||
assert!(db.photos_by_uid(&["p2".into()]).unwrap()[0].favorite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_page_slices_the_timeline_in_order() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let items: Vec<_> = (0..5)
|
||||
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>))
|
||||
.map(|i| (format!("p{i}"), 500 - i as i64, None, None::<String>, None))
|
||||
.collect();
|
||||
db.photos_replace(&items).unwrap();
|
||||
|
||||
let page = db.photos_page(2, 2, None, None).unwrap();
|
||||
let page = db.photos_page(2, 2, None, None, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p2", "p3"]
|
||||
);
|
||||
assert!(db.photos_page(9, 2, None, None).unwrap().is_empty());
|
||||
assert!(db.photos_page(9, 2, None, None, false).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1336,6 +1371,8 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
|
||||
DROP TABLE share_access;
|
||||
DROP TABLE album_photos;
|
||||
DROP TABLE albums;
|
||||
DROP INDEX idx_photos_favorite;
|
||||
ALTER TABLE photos DROP COLUMN favorite;
|
||||
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -1402,6 +1439,18 @@ fn migration_v18_projects_v17_sync_folders_and_cascades_deletes() {
|
||||
VALUES
|
||||
(41, '/home/me/Existing', 'vol~existing', 'share-existing',
|
||||
'ondemand', 'syncing', 123);
|
||||
-- The timeline as V17 held it: later migrations add columns to it,
|
||||
-- so a fixture without it isn't a database any release produced.
|
||||
CREATE TABLE photos (
|
||||
uid TEXT PRIMARY KEY,
|
||||
capture_time INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
ratio REAL,
|
||||
thumb_state INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL,
|
||||
media_type TEXT,
|
||||
kind INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE share_access (
|
||||
root_uid TEXT PRIMARY KEY,
|
||||
access TEXT NOT NULL
|
||||
|
||||
Reference in new issue
Block a user