building out the components, updating the UI in overview and adding a little sqlite viewer
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user