feat: Takeout walkthrough, design-system restyle, replacing import
Adds a seven-step in-app guide to exporting subscriptions from Google Takeout, with the real URLs opened in the system browser. Restyles the app onto the supplied design system: slate/sky palette, 9-15px type ladder, outline-first controls, tiered radii, borders for separation and shadows only for elevation. Light and dark are both designed, with a System/Light/Dark control and a pre-paint script so the window does not flash light on a dark machine. Importing now replaces the subscription list rather than merging, per request. Because that can delete downloaded files, a confirmation dialog names exactly what will go first.
This commit is contained in:
+209
-1
@@ -1,6 +1,8 @@
|
||||
//! SQLite storage. The only module that speaks SQL.
|
||||
|
||||
use crate::models::{Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, Video};
|
||||
use crate::models::{
|
||||
Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Video,
|
||||
};
|
||||
use rusqlite::{params, Connection};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -80,6 +82,130 @@ impl Db {
|
||||
Ok(Db { conn })
|
||||
}
|
||||
|
||||
/// What a replacing import would destroy. Callers show this before asking
|
||||
/// the user to confirm, so nothing is deleted without being named first.
|
||||
pub fn preview_replace(&self, incoming: &[Channel]) -> Result<ImportPreview, String> {
|
||||
let keep: std::collections::HashSet<&str> =
|
||||
incoming.iter().map(|c| c.id.as_str()).collect();
|
||||
|
||||
let mut removed_channels = 0i64;
|
||||
for id in self.channel_ids()? {
|
||||
if !keep.contains(id.as_str()) {
|
||||
removed_channels += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT v.channel_id, COUNT(*),
|
||||
SUM(CASE WHEN d.state = 'done' THEN 1 ELSE 0 END)
|
||||
FROM videos v
|
||||
LEFT JOIN downloads d ON d.video_id = v.id
|
||||
GROUP BY v.channel_id",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, i64>(1)?,
|
||||
r.get::<_, Option<i64>>(2)?.unwrap_or(0),
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut removed_videos = 0i64;
|
||||
let mut removed_downloads = 0i64;
|
||||
for row in rows {
|
||||
let (cid, videos, downloads) = row.map_err(|e| e.to_string())?;
|
||||
if !keep.contains(cid.as_str()) {
|
||||
removed_videos += videos;
|
||||
removed_downloads += downloads;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ImportPreview {
|
||||
incoming: incoming.len() as i64,
|
||||
removed_channels,
|
||||
removed_videos,
|
||||
removed_downloads,
|
||||
})
|
||||
}
|
||||
|
||||
/// Files belonging to channels that a replacing import would drop, so the
|
||||
/// caller can delete them rather than orphaning them on disk.
|
||||
pub fn paths_dropped_by_replace(&self, incoming: &[Channel]) -> Result<Vec<String>, String> {
|
||||
let keep: std::collections::HashSet<&str> =
|
||||
incoming.iter().map(|c| c.id.as_str()).collect();
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT v.channel_id, d.path FROM downloads d
|
||||
JOIN videos v ON v.id = d.video_id
|
||||
WHERE d.path IS NOT NULL",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (cid, path) = row.map_err(|e| e.to_string())?;
|
||||
if !keep.contains(cid.as_str()) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Replaces the subscription list outright: the imported CSV becomes the
|
||||
/// whole truth. Channels no longer present are dropped along with their
|
||||
/// videos and download records. Channels that survive keep their videos and
|
||||
/// download state untouched.
|
||||
pub fn replace_channels(&mut self, channels: &[Channel]) -> Result<usize, String> {
|
||||
let tx = self.conn.transaction().map_err(|e| e.to_string())?;
|
||||
{
|
||||
// A temp table keeps the delete set explicit and avoids building a
|
||||
// giant IN(...) clause for a few hundred channels.
|
||||
tx.execute_batch(
|
||||
"CREATE TEMP TABLE IF NOT EXISTS keep_ids (id TEXT PRIMARY KEY);
|
||||
DELETE FROM keep_ids;",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
{
|
||||
let mut ins = tx
|
||||
.prepare("INSERT OR IGNORE INTO keep_ids (id) VALUES (?1)")
|
||||
.map_err(|e| e.to_string())?;
|
||||
for c in channels {
|
||||
ins.execute(params![c.id]).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.execute_batch(
|
||||
"DELETE FROM downloads WHERE video_id IN (
|
||||
SELECT id FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids));
|
||||
DELETE FROM videos WHERE channel_id NOT IN (SELECT id FROM keep_ids);
|
||||
DELETE FROM channels WHERE id NOT IN (SELECT id FROM keep_ids);",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut stmt = tx
|
||||
.prepare(
|
||||
"INSERT INTO channels (id, title, url, added_at) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(id) DO UPDATE SET title=excluded.title, url=excluded.url",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let ts = now();
|
||||
for c in channels {
|
||||
stmt.execute(params![c.id, c.title, c.url, ts])
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
Ok(channels.len())
|
||||
}
|
||||
|
||||
pub fn upsert_channels(&mut self, channels: &[Channel]) -> Result<usize, String> {
|
||||
let tx = self.conn.transaction().map_err(|e| e.to_string())?;
|
||||
{
|
||||
@@ -391,6 +517,88 @@ mod tests {
|
||||
db
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_drops_channels_absent_from_the_new_csv() {
|
||||
let mut db = seeded();
|
||||
db.set_download_state("b", DownloadState::Done, None).unwrap();
|
||||
|
||||
// New CSV contains only UC1; UC2 (and its downloaded video "b") must go.
|
||||
db.replace_channels(&[Channel {
|
||||
id: "UC1".into(),
|
||||
title: "Alpha".into(),
|
||||
url: "u1".into(),
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
let chans = db.list_channels().unwrap();
|
||||
assert_eq!(chans.len(), 1);
|
||||
assert_eq!(chans[0].id, "UC1");
|
||||
|
||||
let feed = db.list_feed(&FeedFilter::default()).unwrap();
|
||||
assert!(feed.iter().all(|f| f.channel_id == "UC1"));
|
||||
assert!(feed.iter().all(|f| f.id != "b"), "video of dropped channel must go");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_keeps_download_state_for_surviving_channels() {
|
||||
let mut db = seeded();
|
||||
db.set_download_state("a", DownloadState::Done, None).unwrap();
|
||||
db.set_download_path("a", "/movies/a.mp4").unwrap();
|
||||
|
||||
db.replace_channels(&[Channel {
|
||||
id: "UC1".into(),
|
||||
title: "Alpha renamed".into(),
|
||||
url: "u1".into(),
|
||||
}])
|
||||
.unwrap();
|
||||
|
||||
let feed = db.list_feed(&FeedFilter::default()).unwrap();
|
||||
let a = feed.iter().find(|f| f.id == "a").expect("surviving video kept");
|
||||
assert_eq!(a.state, Some(DownloadState::Done));
|
||||
assert_eq!(a.path.as_deref(), Some("/movies/a.mp4"));
|
||||
assert_eq!(a.channel_title, "Alpha renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_counts_what_replacing_would_remove() {
|
||||
let mut db = seeded();
|
||||
db.set_download_state("b", DownloadState::Done, None).unwrap();
|
||||
|
||||
let incoming = vec![Channel { id: "UC1".into(), title: "Alpha".into(), url: "u".into() }];
|
||||
let p = db.preview_replace(&incoming).unwrap();
|
||||
assert_eq!(p.incoming, 1);
|
||||
assert_eq!(p.removed_channels, 1);
|
||||
assert_eq!(p.removed_videos, 1);
|
||||
assert_eq!(p.removed_downloads, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_removes_nothing_when_csv_is_a_superset() {
|
||||
let mut db = seeded();
|
||||
let incoming = vec![
|
||||
Channel { id: "UC1".into(), title: "Alpha".into(), url: "u".into() },
|
||||
Channel { id: "UC2".into(), title: "Beta".into(), url: "u".into() },
|
||||
Channel { id: "UC3".into(), title: "Gamma".into(), url: "u".into() },
|
||||
];
|
||||
let p = db.preview_replace(&incoming).unwrap();
|
||||
assert_eq!(p.removed_channels, 0);
|
||||
assert_eq!(p.removed_videos, 0);
|
||||
assert_eq!(p.removed_downloads, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_paths_lists_only_files_of_removed_channels() {
|
||||
let mut db = seeded();
|
||||
db.set_download_state("a", DownloadState::Done, None).unwrap();
|
||||
db.set_download_path("a", "/movies/a.mp4").unwrap();
|
||||
db.set_download_state("b", DownloadState::Done, None).unwrap();
|
||||
db.set_download_path("b", "/movies/b.mp4").unwrap();
|
||||
|
||||
let incoming = vec![Channel { id: "UC1".into(), title: "Alpha".into(), url: "u".into() }];
|
||||
let paths = db.paths_dropped_by_replace(&incoming).unwrap();
|
||||
assert_eq!(paths, vec!["/movies/b.mp4".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upserting_same_channel_twice_yields_one_row() {
|
||||
let mut db = Db::open_in_memory().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user