//! One-time move of a ThoughtSync install's data into Inkwell's. //! //! The app was renamed ThoughtSync → Inkwell (Scribe note 5071), and the Tauri //! identifier with it. The app-data dir is named after the identifier, so the //! renamed app looks in `com.fabledsword.inkwell` and would start EMPTY beside the //! old `com.fabledsword.thoughtsync`, which still holds every note. Local-first means //! that directory is the only copy for anyone who never linked a server. //! //! So on startup, before the store opens, the old directory's contents move across. //! The store file was renamed with the app (`thoughtsync.db` → `inkwell.db`), so it //! is renamed on the way, together with its SQLite `-wal`/`-shm` sidecars. Those //! hold committed writes not yet checkpointed into the main file, and a database //! renamed without them silently loses them. //! //! A removable shim, deliberately (family rule 22): delete this module and its one //! call in `lib.rs` once no install is still on the old identifier. use std::fs; use std::io; use std::path::Path; use inkwell_core::local::DB_FILE; /// The identifier ThoughtSync shipped under. Its app-data dir is a sibling of ours. pub const LEGACY_IDENTIFIER: &str = "com.fabledsword.thoughtsync"; const LEGACY_DB: &str = "thoughtsync.db"; /// What a crossover did, for the startup log. #[derive(Debug, PartialEq, Eq)] pub enum Outcome { /// No old install beside this one. NothingToMove, /// This install already has its own store. The old directory is left alone: /// merging two stores is not something a rename should decide. AlreadyHasAStore, /// Moved this many entries across. Moved(usize), } /// Move `old_dir`'s contents into `new_dir`, unless `new_dir` already has a store. /// /// Entry by entry rather than renaming the directory, because `new_dir` can already /// exist without a store: the installer writes its channel marker there before the /// app first runs (update.rs, issue 2183), and the webview may create its own files /// before setup. Anything already in `new_dir` wins; the old copy of it is left in /// place, and so is `old_dir` itself unless it ends up empty. pub fn adopt_legacy_data(old_dir: &Path, new_dir: &Path) -> io::Result { if new_dir.join(DB_FILE).exists() { return Ok(Outcome::AlreadyHasAStore); } if !old_dir.is_dir() { return Ok(Outcome::NothingToMove); } fs::create_dir_all(new_dir)?; let mut moved = 0; for entry in fs::read_dir(old_dir)? { let entry = entry?; let name = entry.file_name(); let target = new_dir.join(renamed(&name.to_string_lossy())); if target.exists() { continue; } fs::rename(entry.path(), &target)?; moved += 1; } // Only succeeds when empty, which is the point: whatever was skipped stays put. let _ = fs::remove_dir(old_dir); Ok(Outcome::Moved(moved)) } /// `thoughtsync.db`, `thoughtsync.db-wal`, `thoughtsync.db-shm` → the `inkwell.db` /// family. Everything else keeps its name. fn renamed(name: &str) -> String { match name.strip_prefix(LEGACY_DB) { Some(suffix @ ("" | "-wal" | "-shm")) => format!("{DB_FILE}{suffix}"), _ => name.to_string(), } } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; use std::sync::atomic::{AtomicU32, Ordering}; /// A unique scratch root holding `old/` and `new/`, the shape of a data root /// with both identifiers' directories in it. fn root() -> (PathBuf, PathBuf) { static SEQ: AtomicU32 = AtomicU32::new(0); let n = SEQ.fetch_add(1, Ordering::Relaxed); let root = std::env::temp_dir().join(format!("inkwell-crossover-{}-{n}", std::process::id())); (root.join("old"), root.join("new")) } fn write(path: &Path, contents: &str) { fs::create_dir_all(path.parent().expect("parent")).expect("mkdir"); fs::write(path, contents).expect("write"); } fn read(path: &Path) -> String { fs::read_to_string(path).expect("read") } #[test] fn a_thoughtsync_store_moves_across_with_its_wal_and_blobs() { let (old, new) = root(); write(&old.join("thoughtsync.db"), "main"); write(&old.join("thoughtsync.db-wal"), "uncheckpointed writes"); write(&old.join("thoughtsync.db-shm"), "index"); write(&old.join("blobs/ab/abcdef"), "image bytes"); assert_eq!( adopt_legacy_data(&old, &new).expect("move"), Outcome::Moved(4) ); assert_eq!(read(&new.join("inkwell.db")), "main"); assert_eq!(read(&new.join("inkwell.db-wal")), "uncheckpointed writes"); assert_eq!(read(&new.join("inkwell.db-shm")), "index"); assert_eq!(read(&new.join("blobs/ab/abcdef")), "image bytes"); assert!(!old.exists(), "an emptied old dir is removed"); } #[test] fn the_installers_marker_already_in_the_new_dir_wins() { // install.sh writes the channel marker into the NEW dir before first launch, // so the new dir exists but holds no store. The crossover still has to run, // and the fresh marker has to beat the old install's. let (old, new) = root(); write(&old.join("thoughtsync.db"), "main"); write(&old.join("install-channel"), "stable"); write(&new.join("install-channel"), "dev"); assert_eq!( adopt_legacy_data(&old, &new).expect("move"), Outcome::Moved(1) ); assert_eq!(read(&new.join("inkwell.db")), "main"); assert_eq!(read(&new.join("install-channel")), "dev"); assert_eq!( read(&old.join("install-channel")), "stable", "the loser stays put" ); } #[test] fn an_install_with_its_own_store_is_left_alone() { let (old, new) = root(); write(&old.join("thoughtsync.db"), "old notes"); write(&new.join("inkwell.db"), "new notes"); assert_eq!( adopt_legacy_data(&old, &new).expect("check"), Outcome::AlreadyHasAStore ); assert_eq!(read(&new.join("inkwell.db")), "new notes"); assert_eq!(read(&old.join("thoughtsync.db")), "old notes"); } #[test] fn no_old_install_means_nothing_happens() { let (old, new) = root(); assert_eq!( adopt_legacy_data(&old, &new).expect("check"), Outcome::NothingToMove ); assert!(!new.exists(), "nothing created for a fresh install"); } #[test] fn only_the_store_family_is_renamed() { assert_eq!(renamed("thoughtsync.db"), "inkwell.db"); assert_eq!(renamed("thoughtsync.db-wal"), "inkwell.db-wal"); assert_eq!(renamed("thoughtsync.db-journal"), "thoughtsync.db-journal"); assert_eq!(renamed("blobs"), "blobs"); } }