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:
vincent
2026-08-29 03:00:54 +02:00
parent 11331671c5
commit cac6727072
18 changed files with 1202 additions and 306 deletions
+40 -8
View File
@@ -3,7 +3,9 @@
use crate::db::Db;
use crate::downloader::{self, Progress};
use crate::feed;
use crate::models::{ChannelWithCount, DownloadState, FeedFilter, FeedItem, Prereqs};
use crate::models::{
Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Prereqs,
};
use crate::net;
use crate::takeout;
use crate::thumbs;
@@ -113,12 +115,8 @@ pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String
})
}
#[tauri::command]
pub async fn import_takeout_csv(
path: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
let raw = tokio::fs::read(&path)
async fn read_channels(path: &str) -> Result<Vec<Channel>, String> {
let raw = tokio::fs::read(path)
.await
.map_err(|e| format!("Cannot read {path}: {e}"))?;
// Takeout exports are UTF-8, sometimes with a BOM.
@@ -129,8 +127,42 @@ pub async fn import_takeout_csv(
if channels.is_empty() {
return Err("No channels found in that file.".into());
}
Ok(channels)
}
/// Reports what a replacing import would add and destroy, so the UI can name
/// the consequences before the user commits to them.
#[tauri::command]
pub async fn preview_takeout_import(
path: String,
state: State<'_, AppState>,
) -> Result<ImportPreview, String> {
let channels = read_channels(&path).await?;
state.db.lock().await.preview_replace(&channels)
}
/// The imported CSV becomes the entire subscription list. Channels that are no
/// longer in it are removed along with their videos, download records, and the
/// downloaded files themselves — leaving those on disk would orphan gigabytes
/// the app can no longer show or delete.
#[tauri::command]
pub async fn import_takeout_csv(
path: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
let channels = read_channels(&path).await?;
let doomed = state
.db
.lock()
.await
.paths_dropped_by_replace(&channels)?;
for p in doomed {
let _ = tokio::fs::remove_file(&p).await;
}
let mut db = state.db.lock().await;
db.upsert_channels(&channels)
db.replace_channels(&channels)
}
#[tauri::command]
+209 -1
View File
@@ -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();
+1
View File
@@ -22,6 +22,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
commands::check_prereqs,
commands::import_takeout_csv,
commands::preview_takeout_import,
commands::list_channels,
commands::list_feed,
commands::refresh_feeds,
+9
View File
@@ -102,3 +102,12 @@ pub struct Prereqs {
pub ffmpeg: Option<String>,
pub library_path: String,
}
/// What a replacing Takeout import would add and destroy.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImportPreview {
pub incoming: i64,
pub removed_channels: i64,
pub removed_videos: i64,
pub removed_downloads: i64,
}