200 lines
7.1 KiB
Rust
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);
|
|
}
|
|
} |