Files
dm-pal/src-tauri/src/commands/data_commands.rs
T

200 lines
7.1 KiB
Rust

use crate::llm::AppState;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri::Manager;
use tauri_plugin_store::StoreExt;
// ponytail: the data-dir pref store + key are defined in lib.rs; mirror them
// here so this module is self-contained for reads/writes.
const PREFS_STORE: &str = "dm-pal-prefs.json";
const DATA_DIR_KEY: &str = "dataDir";
#[derive(Debug, Serialize)]
pub struct DataDirInfo {
/// Absolute path currently in use (may differ from `configured` if it was
/// unset/fell back to the default).
pub current: String,
/// Absolute path that will be used after the next restart, if different.
pub configured: Option<String>,
/// The OS-default location ($APPDATA/dm-toolkit).
pub default: String,
}
/// Return the current data dir + the configured (pending-restart) value, so
/// Settings can show "using X — restart to apply Y".
#[tauri::command]
pub fn get_data_dir(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
) -> Result<DataDirInfo, String> {
let default = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("dm-toolkit");
let configured = app
.store(PREFS_STORE)
.map_err(|e| e.to_string())?
.get(DATA_DIR_KEY)
.and_then(|v| v.as_str().map(|s| s.to_string()));
Ok(DataDirInfo {
current: state.data_dir.display().to_string(),
configured,
default: default.display().to_string(),
})
}
/// Subdirs/files that make up the campaign data and must move together.
const DATA_RELATIVE: &[&str] = &["lore/lore.db", "generations.db", "images"];
/// Recursively copy `src` to `dst`, but never overwrite an existing file —
/// a re-locate must not clobber data already at the destination, and never
/// deletes the source (the old location stays as a backup until the DM deletes it).
/// When both src and dst are directories, recurse so new files inside an
/// existing destination tree still get copied.
fn copy_if_absent(src: &Path, dst: &Path) -> std::io::Result<()> {
if !src.exists() {
return Ok(());
}
if src.is_dir() {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
copy_if_absent(&entry.path(), &dst.join(entry.file_name()))?;
}
return Ok(());
}
// leaf file: skip if the destination already has it (no overwrite).
if dst.exists() {
return Ok(());
}
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(src, dst)?;
Ok(())
}
/// Set the data dir to `new_dir`: create it, copy existing campaign data
/// (lore.db, generations.db, images/) into it, and persist the preference so
/// the next restart uses it. The currently-open SQLite connections stay on the
/// old paths until restart — the caller must prompt the user to relaunch.
#[tauri::command]
pub fn set_data_dir(
app: tauri::AppHandle,
state: tauri::State<'_, AppState>,
new_dir: String,
) -> Result<DataDirInfo, String> {
let new_path = PathBuf::from(new_dir.trim());
if !new_path.is_absolute() {
return Err("Choose an absolute folder path".into());
}
std::fs::create_dir_all(&new_path).map_err(|e| format!("can't create dir: {e}"))?;
// Copy existing data into the new location (no overwrite; old data stays put).
for rel in DATA_RELATIVE {
let src = state.data_dir.join(rel);
let dst = new_path.join(rel);
if src.exists() {
copy_if_absent(&src, &dst).map_err(|e| format!("migrate {rel}: {e}"))?;
}
}
// Persist the preference for the next startup.
let store = app.store(PREFS_STORE).map_err(|e| e.to_string())?;
store.set(DATA_DIR_KEY, serde_json::Value::String(new_path.display().to_string()));
store.save().map_err(|e| e.to_string())?;
Ok(DataDirInfo {
current: state.data_dir.display().to_string(),
configured: Some(new_path.display().to_string()),
default: app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("dm-toolkit")
.display()
.to_string(),
})
}
/// Clear the preference so the next restart uses the OS default. Does not
/// delete any files — the DM re-points, then restarts.
#[tauri::command]
pub fn reset_data_dir(app: tauri::AppHandle) -> Result<(), String> {
let store = app.store(PREFS_STORE).map_err(|e| e.to_string())?;
store.delete(DATA_DIR_KEY);
store.save().map_err(|e| e.to_string())?;
Ok(())
}
// ─── SQLite viewer (read-only) ─────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct SqlQueryReq {
/// "lore" or "generations".
pub db: String,
pub sql: String,
}
#[derive(Debug, Serialize)]
pub struct SqlQueryResult {
pub columns: Vec<String>,
pub rows: Vec<Vec<serde_json::Value>>,
}
/// Run a read-only SQL query against one of the two SQLite DBs so a DM can
/// inspect the backend from Settings → Advanced. Only SELECT/PRAGMA/WITH.
#[tauri::command]
pub fn sql_query(state: tauri::State<'_, AppState>, req: SqlQueryReq) -> Result<SqlQueryResult, String> {
let res = match req.db.as_str() {
"lore" => state.rag.raw_query(&req.sql),
"generations" => state.gen.raw_query(&req.sql),
other => Err(anyhow::anyhow!("unknown db: {other}")),
}
.map_err(|e| e.to_string())?;
Ok(SqlQueryResult { columns: res.0, rows: res.1 })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copy_if_absent_does_not_overwrite() {
let tmp = std::env::temp_dir().join(format!("dm-pal-data-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let src = tmp.join("src");
let dst = tmp.join("dst");
std::fs::create_dir_all(src.join("images")).unwrap();
std::fs::write(src.join("images/a.png"), b"png").unwrap();
std::fs::create_dir_all(src.join("lore")).unwrap();
std::fs::write(src.join("lore/lore.db"), b"db").unwrap();
// first copy creates dst with the files
copy_if_absent(&src, &dst).unwrap();
assert!(dst.join("images/a.png").exists());
assert!(dst.join("lore/lore.db").exists());
// mutate src; second copy must NOT overwrite existing dst files
std::fs::write(src.join("images/a.png"), b"changed").unwrap();
copy_if_absent(&src, &dst).unwrap();
assert_eq!(std::fs::read(dst.join("images/a.png")).unwrap(), b"png");
// a new file in src does get copied (absent in dst)
std::fs::write(src.join("images/b.png"), b"png2").unwrap();
copy_if_absent(&src, &dst).unwrap();
assert!(dst.join("images/b.png").exists());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn copy_if_absent_missing_src_is_noop() {
let tmp = std::env::temp_dir().join(format!("dm-pal-data-test2-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
copy_if_absent(&tmp.join("nope"), &tmp.join("dst")).unwrap();
assert!(!tmp.join("dst").exists());
let _ = std::fs::remove_dir_all(&tmp);
}
}