Replace all instances of the legacy product names "Kon" and "Corbie" with "Magnotia" across user-facing copy, code identifiers, package names, bundle ids, file paths, and documentation. Preserves the unrelated "konsole" (KDE terminal) reference and the parent CORBEL company name. - Renames 10 Rust crates (kon-* → magnotia-*) and the tauri binary - Updates package.json, tauri.conf.json (productName + identifier) - Renames CSS classes (kon-rh-* → magnotia-rh-*) and animations - Renames brand and roadmap docs - Regenerates Cargo.lock and package-lock.json Verified: svelte-check passes; pure-rust crates compile under new names.
2535 lines
88 KiB
Rust
2535 lines
88 KiB
Rust
use std::path::Path;
|
|
|
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
|
use sqlx::{Row, SqlitePool};
|
|
|
|
use magnotia_core::error::{MagnotiaError, Result};
|
|
|
|
/// Initialise the SQLite database with connection pool and run migrations.
|
|
pub async fn init(db_path: &Path) -> Result<SqlitePool> {
|
|
if let Some(parent) = db_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
let options = SqliteConnectOptions::new()
|
|
.filename(db_path)
|
|
.create_if_missing(true);
|
|
|
|
let pool = SqlitePoolOptions::new()
|
|
.max_connections(5)
|
|
.connect_with(options)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Database connect failed: {e}")))?;
|
|
|
|
sqlx::query("PRAGMA foreign_keys = ON")
|
|
.execute(&pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("foreign_keys pragma failed: {e}")))?;
|
|
|
|
run_migrations(&pool).await?;
|
|
|
|
Ok(pool)
|
|
}
|
|
|
|
/// Open the SQLite database in read-only mode without running migrations.
|
|
///
|
|
/// Used by `magnotia-mcp` so the MCP server cannot write to the user's database
|
|
/// regardless of which tools the dispatcher exposes — `read_only(true)` makes
|
|
/// the constraint structural rather than relying on the request handler being
|
|
/// well-behaved. Fails cleanly if the DB doesn't exist (no `create_if_missing`).
|
|
pub async fn init_readonly(db_path: &Path) -> Result<SqlitePool> {
|
|
let options = SqliteConnectOptions::new()
|
|
.filename(db_path)
|
|
.create_if_missing(false)
|
|
.read_only(true);
|
|
|
|
SqlitePoolOptions::new()
|
|
.max_connections(2)
|
|
.connect_with(options)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Read-only connect failed: {e}")))
|
|
}
|
|
|
|
/// Run schema migrations via the versioned migration system.
|
|
async fn run_migrations(pool: &SqlitePool) -> Result<()> {
|
|
crate::migrations::run_migrations(pool).await
|
|
}
|
|
|
|
// --- Transcript CRUD ---
|
|
|
|
/// Parameters for inserting a transcript with full provenance.
|
|
pub struct InsertTranscriptParams<'a> {
|
|
pub id: &'a str,
|
|
pub text: &'a str,
|
|
pub source: &'a str,
|
|
pub profile_id: &'a str,
|
|
pub title: Option<&'a str>,
|
|
pub audio_path: Option<&'a str>,
|
|
pub duration: f64,
|
|
pub engine: Option<&'a str>,
|
|
pub model_id: Option<&'a str>,
|
|
pub inference_ms: Option<i64>,
|
|
pub sample_rate: Option<i64>,
|
|
pub audio_channels: Option<i64>,
|
|
pub format_mode: Option<&'a str>,
|
|
pub remove_fillers: bool,
|
|
pub british_english: bool,
|
|
pub anti_hallucination: bool,
|
|
}
|
|
|
|
pub async fn insert_transcript(
|
|
pool: &SqlitePool,
|
|
params: &InsertTranscriptParams<'_>,
|
|
) -> Result<()> {
|
|
if !profile_exists(pool, params.profile_id).await? {
|
|
return Err(MagnotiaError::StorageError(format!(
|
|
"Insert transcript failed: unknown profile id '{}'",
|
|
params.profile_id
|
|
)));
|
|
}
|
|
|
|
sqlx::query(
|
|
"INSERT INTO transcripts (id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(params.id)
|
|
.bind(params.text)
|
|
.bind(params.source)
|
|
.bind(params.profile_id)
|
|
.bind(params.title)
|
|
.bind(params.audio_path)
|
|
.bind(params.duration)
|
|
.bind(params.engine)
|
|
.bind(params.model_id)
|
|
.bind(params.inference_ms)
|
|
.bind(params.sample_rate)
|
|
.bind(params.audio_channels)
|
|
.bind(params.format_mode)
|
|
.bind(params.remove_fillers)
|
|
.bind(params.british_english)
|
|
.bind(params.anti_hallucination)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Insert transcript failed: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<TranscriptRow>> {
|
|
let row = sqlx::query(
|
|
"SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags FROM transcripts WHERE id = ?",
|
|
)
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Get transcript failed: {e}")))?;
|
|
|
|
Ok(row.map(|r| transcript_row_from(&r)))
|
|
}
|
|
|
|
pub async fn list_transcripts(pool: &SqlitePool, limit: i64) -> Result<Vec<TranscriptRow>> {
|
|
list_transcripts_paged(pool, limit, 0).await
|
|
}
|
|
|
|
/// Paginated transcript list. `limit` is rows-per-page, `offset` is rows to skip.
|
|
/// Used by HistoryPage to load 50 at a time and append more on scroll.
|
|
pub async fn list_transcripts_paged(
|
|
pool: &SqlitePool,
|
|
limit: i64,
|
|
offset: i64,
|
|
) -> Result<Vec<TranscriptRow>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, text, source, profile_id, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json, llm_tags FROM transcripts ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
|
)
|
|
.bind(limit)
|
|
.bind(offset)
|
|
.fetch_all(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("List transcripts failed: {e}")))?;
|
|
|
|
Ok(rows.iter().map(transcript_row_from).collect())
|
|
}
|
|
|
|
/// Total count of transcripts. Useful for displaying "showing 50 of 312" in the UI.
|
|
pub async fn count_transcripts(pool: &SqlitePool) -> Result<i64> {
|
|
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts")
|
|
.fetch_one(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Count transcripts failed: {e}")))?;
|
|
Ok(n)
|
|
}
|
|
|
|
/// Update mutable transcript fields. Currently `text` and `title`. Other
|
|
/// columns (engine, model, audio path) are immutable post-creation. Returns
|
|
/// the number of rows updated (0 if id not found).
|
|
///
|
|
/// This is the function HistoryPage's rename flow needed; previously the
|
|
/// rename was a UI-only state change with a TODO never wired up.
|
|
/// (architecture-review.md §13)
|
|
pub async fn update_transcript(
|
|
pool: &SqlitePool,
|
|
id: &str,
|
|
text: Option<&str>,
|
|
title: Option<&str>,
|
|
) -> Result<u64> {
|
|
// Build the SET clause dynamically so we only update the fields the
|
|
// caller actually changed. Two fields max so we can keep this simple
|
|
// without a query builder.
|
|
match (text, title) {
|
|
(Some(t), Some(ttl)) => {
|
|
let res = sqlx::query("UPDATE transcripts SET text = ?, title = ? WHERE id = ?")
|
|
.bind(t)
|
|
.bind(ttl)
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Update transcript failed: {e}")))?;
|
|
Ok(res.rows_affected())
|
|
}
|
|
(Some(t), None) => {
|
|
let res = sqlx::query("UPDATE transcripts SET text = ? WHERE id = ?")
|
|
.bind(t)
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Update transcript failed: {e}")))?;
|
|
Ok(res.rows_affected())
|
|
}
|
|
(None, Some(ttl)) => {
|
|
let res = sqlx::query("UPDATE transcripts SET title = ? WHERE id = ?")
|
|
.bind(ttl)
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Update transcript failed: {e}")))?;
|
|
Ok(res.rows_affected())
|
|
}
|
|
(None, None) => Ok(0),
|
|
}
|
|
}
|
|
|
|
/// Patch-style update for the Task 2.5 transcripts_meta columns (starred,
|
|
/// manual_tags, template, language, segments_json). Follows the same
|
|
/// COALESCE pattern as `update_task`: each `Option::Some` overwrites,
|
|
/// each `None` preserves the existing column value. Returns the refreshed
|
|
/// row or errors if `id` does not exist after the UPDATE.
|
|
///
|
|
/// Keeping this separate from `update_transcript` (text / title) means the
|
|
/// existing command surface stays stable — the viewer and history store
|
|
/// keep calling `update_transcript` for content edits, and call this for
|
|
/// UI-metadata persistence.
|
|
#[allow(clippy::too_many_arguments)] // Each Option maps to one COALESCE.
|
|
pub async fn update_transcript_meta(
|
|
pool: &SqlitePool,
|
|
id: &str,
|
|
starred: Option<bool>,
|
|
manual_tags: Option<&str>,
|
|
template: Option<&str>,
|
|
language: Option<&str>,
|
|
segments_json: Option<&str>,
|
|
llm_tags: Option<&str>,
|
|
) -> Result<TranscriptRow> {
|
|
sqlx::query(
|
|
"UPDATE transcripts SET \
|
|
starred = COALESCE(?, starred), \
|
|
manual_tags = COALESCE(?, manual_tags), \
|
|
template = COALESCE(?, template), \
|
|
language = COALESCE(?, language), \
|
|
segments_json = COALESCE(?, segments_json), \
|
|
llm_tags = COALESCE(?, llm_tags) \
|
|
WHERE id = ?",
|
|
)
|
|
.bind(starred.map(|b| b as i64))
|
|
.bind(manual_tags)
|
|
.bind(template)
|
|
.bind(language)
|
|
.bind(segments_json)
|
|
.bind(llm_tags)
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("update_transcript_meta: {e}")))?;
|
|
|
|
get_transcript(pool, id).await?.ok_or_else(|| {
|
|
MagnotiaError::StorageError(format!("update_transcript_meta: transcript {id} not found"))
|
|
})
|
|
}
|
|
|
|
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
|
sqlx::query("DELETE FROM transcripts WHERE id = ?")
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Delete transcript failed: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Full-text search over transcripts using the FTS5 virtual table created
|
|
/// in migration v2. Returns matched transcript rows ordered by FTS rank
|
|
/// (best match first). `query` follows FTS5 syntax: bare words AND together
|
|
/// by default; quote multi-word phrases; `OR`, `NOT` operators supported.
|
|
pub async fn search_transcripts(
|
|
pool: &SqlitePool,
|
|
query: &str,
|
|
limit: i64,
|
|
) -> Result<Vec<TranscriptRow>> {
|
|
let rows = sqlx::query(
|
|
"SELECT t.id, t.text, t.source, t.profile_id, t.title, t.audio_path, t.duration, t.engine, t.model_id, t.inference_ms, t.sample_rate, t.audio_channels, t.format_mode, t.remove_fillers, t.british_english, t.anti_hallucination, t.created_at, t.starred, t.manual_tags, t.template, t.language, t.segments_json, t.llm_tags \
|
|
FROM transcripts t \
|
|
JOIN transcripts_fts fts ON fts.rowid = t.rowid \
|
|
WHERE transcripts_fts MATCH ? \
|
|
ORDER BY fts.rank LIMIT ?",
|
|
)
|
|
.bind(query)
|
|
.bind(limit)
|
|
.fetch_all(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("FTS search failed: {e}")))?;
|
|
|
|
Ok(rows.iter().map(transcript_row_from).collect())
|
|
}
|
|
|
|
// --- Task CRUD ---
|
|
|
|
/// Insert a task. `list_id` and `effort` are nullable (schema predates their
|
|
/// UI surfacing); `notes` defaults to '' at the column level. Callers that
|
|
/// want to set metadata at creation time pass `Some(...)`; omit for defaults.
|
|
///
|
|
/// Positional signature is deliberately flat — it mirrors the `tasks`
|
|
/// schema columns one-to-one. Refactor to a params struct only if another
|
|
/// nullable is added after `energy`.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn insert_task(
|
|
pool: &SqlitePool,
|
|
id: &str,
|
|
text: &str,
|
|
bucket: &str,
|
|
source_transcript_id: Option<&str>,
|
|
list_id: Option<&str>,
|
|
effort: Option<&str>,
|
|
energy: Option<&str>,
|
|
) -> Result<()> {
|
|
sqlx::query(
|
|
"INSERT INTO tasks (id, text, bucket, source_transcript_id, list_id, effort, energy) \
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(id)
|
|
.bind(text)
|
|
.bind(bucket)
|
|
.bind(source_transcript_id)
|
|
.bind(list_id)
|
|
.bind(effort)
|
|
.bind(energy)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Insert task failed: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
|
|
source_transcript_id, parent_task_id, energy \
|
|
FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC",
|
|
)
|
|
.fetch_all(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("List tasks failed: {e}")))?;
|
|
|
|
Ok(rows.into_iter().map(task_row_from).collect())
|
|
}
|
|
|
|
pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> {
|
|
let row = sqlx::query(
|
|
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
|
|
source_transcript_id, parent_task_id, energy FROM tasks WHERE id = ?",
|
|
)
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Get task failed: {e}")))?;
|
|
|
|
Ok(row.map(task_row_from))
|
|
}
|
|
|
|
/// Patch-style update. Each `Option` that is `Some` overwrites the column;
|
|
/// `None` preserves the existing value via COALESCE. Returns the refreshed
|
|
/// row, or an error if `id` does not exist after the UPDATE.
|
|
///
|
|
/// Intentionally does not touch `done` / `done_at` — those go through the
|
|
/// dedicated `complete_task` / `uncomplete_task` commands because they also
|
|
/// set the server-side timestamp.
|
|
pub async fn update_task(
|
|
pool: &SqlitePool,
|
|
id: &str,
|
|
text: Option<&str>,
|
|
bucket: Option<&str>,
|
|
list_id: Option<&str>,
|
|
effort: Option<&str>,
|
|
notes: Option<&str>,
|
|
) -> Result<TaskRow> {
|
|
sqlx::query(
|
|
"UPDATE tasks SET \
|
|
text = COALESCE(?, text), \
|
|
bucket = COALESCE(?, bucket), \
|
|
list_id = COALESCE(?, list_id), \
|
|
effort = COALESCE(?, effort), \
|
|
notes = COALESCE(?, notes) \
|
|
WHERE id = ?",
|
|
)
|
|
.bind(text)
|
|
.bind(bucket)
|
|
.bind(list_id)
|
|
.bind(effort)
|
|
.bind(notes)
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Update task failed: {e}")))?;
|
|
|
|
get_task_by_id(pool, id).await?.ok_or_else(|| {
|
|
MagnotiaError::StorageError(format!("update_task: task {id} not found after update"))
|
|
})
|
|
}
|
|
|
|
/// Dedicated tri-state energy setter. Exists as its own function because
|
|
/// `update_task` uses `COALESCE(?, col)` to let `None` mean "preserve" —
|
|
/// which makes it impossible to explicitly clear energy back to NULL.
|
|
/// `set_task_energy` always writes exactly the value passed, including
|
|
/// `None` to clear. Returns the refreshed row.
|
|
///
|
|
/// Caller is responsible for validating `energy` is one of the allowed
|
|
/// values; the CHECK constraint will reject anything else at commit time.
|
|
pub async fn set_task_energy(pool: &SqlitePool, id: &str, energy: Option<&str>) -> Result<TaskRow> {
|
|
sqlx::query("UPDATE tasks SET energy = ? WHERE id = ?")
|
|
.bind(energy)
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("set_task_energy failed: {e}")))?;
|
|
|
|
get_task_by_id(pool, id).await?.ok_or_else(|| {
|
|
MagnotiaError::StorageError(format!("set_task_energy: task {id} not found after update"))
|
|
})
|
|
}
|
|
|
|
pub async fn insert_subtask(
|
|
pool: &SqlitePool,
|
|
id: &str,
|
|
text: &str,
|
|
parent_task_id: &str,
|
|
) -> Result<()> {
|
|
sqlx::query("INSERT INTO tasks (id, text, bucket, parent_task_id) VALUES (?, ?, 'inbox', ?)")
|
|
.bind(id)
|
|
.bind(text)
|
|
.bind(parent_task_id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Insert subtask failed: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<TaskRow>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
|
|
source_transcript_id, parent_task_id, energy \
|
|
FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC",
|
|
)
|
|
.bind(parent_id)
|
|
.fetch_all(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("List subtasks failed: {e}")))?;
|
|
|
|
Ok(rows.into_iter().map(task_row_from).collect())
|
|
}
|
|
|
|
/// Mark a subtask done. If all siblings are now done, auto-complete the parent.
|
|
/// Runs in a transaction so concurrent completions see consistent sibling counts.
|
|
pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> {
|
|
let mut tx = pool
|
|
.begin()
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Begin transaction failed: {e}")))?;
|
|
|
|
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
|
|
.bind(subtask_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Complete subtask failed: {e}")))?;
|
|
|
|
let parent_id: Option<String> =
|
|
sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?")
|
|
.bind(subtask_id)
|
|
.fetch_one(&mut *tx)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Get parent_task_id failed: {e}")))?;
|
|
|
|
if let Some(pid) = parent_id {
|
|
let pending: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0")
|
|
.bind(&pid)
|
|
.fetch_one(&mut *tx)
|
|
.await
|
|
.map_err(|e| {
|
|
MagnotiaError::StorageError(format!("Count pending subtasks failed: {e}"))
|
|
})?;
|
|
|
|
if pending == 0 {
|
|
// Phase 8: flag the cascade so the daily-count query can exclude
|
|
// it. The subtask UPDATE (above) stays at auto_completed = 0. The
|
|
// user explicitly ticked it, so it counts.
|
|
sqlx::query(
|
|
"UPDATE tasks SET done = 1, done_at = datetime('now'), auto_completed = 1 \
|
|
WHERE id = ?",
|
|
)
|
|
.bind(&pid)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Auto-complete parent failed: {e}")))?;
|
|
}
|
|
}
|
|
|
|
tx.commit()
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Commit transaction failed: {e}")))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
|
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Complete task failed: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
|
let mut tx = pool
|
|
.begin()
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Begin transaction failed: {e}")))?;
|
|
|
|
sqlx::query("UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 WHERE id = ?")
|
|
.bind(id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Uncomplete task failed: {e}")))?;
|
|
|
|
// Mirror the auto-complete invariant from
|
|
// `complete_subtask_and_check_parent`: a parent task is done iff
|
|
// every child is done. If the child we just reopened had a done
|
|
// parent, reopen the parent too so state stays consistent
|
|
// (2026-04-22 review MAJOR). No-op for top-level tasks.
|
|
let parent_id: Option<String> =
|
|
sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?")
|
|
.bind(id)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Get parent_task_id failed: {e}")))?
|
|
.flatten();
|
|
|
|
if let Some(pid) = parent_id {
|
|
sqlx::query(
|
|
"UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 \
|
|
WHERE id = ? AND done = 1",
|
|
)
|
|
.bind(&pid)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Reopen parent failed: {e}")))?;
|
|
}
|
|
|
|
tx.commit()
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Commit transaction failed: {e}")))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
|
sqlx::query("DELETE FROM tasks WHERE id = ?")
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Delete task failed: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
// --- Phase 8: daily completion counts -----------------------------------
|
|
//
|
|
// Drives the Tasks-page badge ("3 today") and the 7-day momentum
|
|
// sparkline. Counts every row whose done_at falls on a given local
|
|
// day, excluding auto-cascade parents (auto_completed = 1). Subtasks
|
|
// count on explicit completion.
|
|
//
|
|
// The query groups by local calendar day (DATE(done_at, 'localtime')
|
|
// since done_at is stored as UTC). The Rust side then left-joins the
|
|
// grouped result against a generated N-day spine so empty days are
|
|
// explicit zeros, not missing entries. The frontend sparkline wants a
|
|
// fixed-length array it can render as 7 bars.
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct DailyCompletionCount {
|
|
pub day: String, // "YYYY-MM-DD" in local time
|
|
pub count: u32,
|
|
}
|
|
|
|
pub async fn list_recent_completions(
|
|
pool: &SqlitePool,
|
|
days: u32,
|
|
) -> Result<Vec<DailyCompletionCount>> {
|
|
// Guard: clamp to [1, 365]. The frontend only ever asks for 7 but
|
|
// a zero or wild value here would produce an empty or huge series.
|
|
let days = days.clamp(1, 365);
|
|
|
|
// Pull the grouped counts from SQLite. We do not generate the spine
|
|
// in SQL. It is easier to left-join in Rust and keep the query simple.
|
|
// Use tuple form because the storage crate does not enable sqlx's
|
|
// `derive` feature (FromRow macro is gated behind it).
|
|
let rows: Vec<(String, i64)> = sqlx::query_as(
|
|
"SELECT DATE(done_at, 'localtime') AS day, COUNT(*) AS count \
|
|
FROM tasks \
|
|
WHERE done = 1 \
|
|
AND done_at IS NOT NULL \
|
|
AND auto_completed = 0 \
|
|
AND DATE(done_at, 'localtime') >= DATE('now', 'localtime', ?) \
|
|
GROUP BY day",
|
|
)
|
|
.bind(format!("-{} days", days - 1))
|
|
.fetch_all(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("List recent completions failed: {e}")))?;
|
|
|
|
let lookup: std::collections::HashMap<String, u32> = rows
|
|
.into_iter()
|
|
.map(|(day, count)| (day, count.max(0) as u32))
|
|
.collect();
|
|
|
|
// Build the spine. `today` is local-day. Walk back `days - 1` entries.
|
|
let today_row: (String,) = sqlx::query_as("SELECT DATE('now', 'localtime')")
|
|
.fetch_one(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Get local today failed: {e}")))?;
|
|
let today = today_row.0;
|
|
|
|
let mut series = Vec::with_capacity(days as usize);
|
|
for offset in (0..days as i64).rev() {
|
|
// Re-query SQLite for each offset so the date arithmetic is the
|
|
// same calendar SQLite uses (month boundaries, DST, etc.).
|
|
let (day,): (String,) = sqlx::query_as("SELECT DATE(?, ?)")
|
|
.bind(&today)
|
|
.bind(format!("-{offset} days"))
|
|
.fetch_one(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Compute spine day failed: {e}")))?;
|
|
let count = lookup.get(&day).copied().unwrap_or(0);
|
|
series.push(DailyCompletionCount { day, count });
|
|
}
|
|
|
|
Ok(series)
|
|
}
|
|
|
|
// --- Implementation intentions ---
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn insert_implementation_rule(
|
|
pool: &SqlitePool,
|
|
id: &str,
|
|
enabled: bool,
|
|
trigger_kind: &str,
|
|
trigger_value: &str,
|
|
actions_json: &str,
|
|
last_fired_key: Option<&str>,
|
|
) -> Result<ImplementationRuleRow> {
|
|
sqlx::query(
|
|
"INSERT INTO implementation_rules (
|
|
id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key
|
|
) VALUES (?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(id)
|
|
.bind(enabled as i64)
|
|
.bind(trigger_kind)
|
|
.bind(trigger_value)
|
|
.bind(actions_json)
|
|
.bind(last_fired_key)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Insert implementation rule failed: {e}")))?;
|
|
|
|
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
|
MagnotiaError::StorageError(format!(
|
|
"insert_implementation_rule: rule {id} not found after insert"
|
|
))
|
|
})
|
|
}
|
|
|
|
pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<ImplementationRuleRow>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, \
|
|
created_at, updated_at \
|
|
FROM implementation_rules ORDER BY created_at DESC",
|
|
)
|
|
.fetch_all(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("List implementation rules failed: {e}")))?;
|
|
|
|
Ok(rows.into_iter().map(implementation_rule_row_from).collect())
|
|
}
|
|
|
|
pub async fn get_implementation_rule(
|
|
pool: &SqlitePool,
|
|
id: &str,
|
|
) -> Result<Option<ImplementationRuleRow>> {
|
|
let row = sqlx::query(
|
|
"SELECT id, enabled, trigger_kind, trigger_value, actions_json, last_fired_key, \
|
|
created_at, updated_at \
|
|
FROM implementation_rules WHERE id = ?",
|
|
)
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Get implementation rule failed: {e}")))?;
|
|
|
|
Ok(row.map(implementation_rule_row_from))
|
|
}
|
|
|
|
pub async fn set_implementation_rule_enabled(
|
|
pool: &SqlitePool,
|
|
id: &str,
|
|
enabled: bool,
|
|
) -> Result<ImplementationRuleRow> {
|
|
sqlx::query(
|
|
"UPDATE implementation_rules
|
|
SET enabled = ?, updated_at = datetime('now')
|
|
WHERE id = ?",
|
|
)
|
|
.bind(enabled as i64)
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Set implementation rule enabled failed: {e}")))?;
|
|
|
|
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
|
MagnotiaError::StorageError(format!(
|
|
"set_implementation_rule_enabled: rule {id} not found after update"
|
|
))
|
|
})
|
|
}
|
|
|
|
pub async fn mark_implementation_rule_fired(
|
|
pool: &SqlitePool,
|
|
id: &str,
|
|
last_fired_key: &str,
|
|
) -> Result<ImplementationRuleRow> {
|
|
sqlx::query(
|
|
"UPDATE implementation_rules
|
|
SET last_fired_key = ?, updated_at = datetime('now')
|
|
WHERE id = ?",
|
|
)
|
|
.bind(last_fired_key)
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Mark implementation rule fired failed: {e}")))?;
|
|
|
|
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
|
MagnotiaError::StorageError(format!(
|
|
"mark_implementation_rule_fired: rule {id} not found after update"
|
|
))
|
|
})
|
|
}
|
|
|
|
pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<()> {
|
|
sqlx::query("DELETE FROM implementation_rules WHERE id = ?")
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Delete implementation rule failed: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
// --- Settings CRUD ---
|
|
|
|
pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()> {
|
|
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
|
|
.bind(key)
|
|
.bind(value)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Set setting failed: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result<Option<String>> {
|
|
let row = sqlx::query("SELECT value FROM settings WHERE key = ?")
|
|
.bind(key)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Get setting failed: {e}")))?;
|
|
Ok(row.map(|r| r.get("value")))
|
|
}
|
|
|
|
// --- Row types ---
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProfileRow {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub initial_prompt: String,
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ProfileTermRow {
|
|
pub id: String,
|
|
pub profile_id: String,
|
|
pub term: String,
|
|
pub note: String,
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct TranscriptRow {
|
|
pub id: String,
|
|
pub text: String,
|
|
pub source: String,
|
|
pub profile_id: String,
|
|
pub title: Option<String>,
|
|
pub audio_path: Option<String>,
|
|
pub duration: f64,
|
|
pub engine: Option<String>,
|
|
pub model_id: Option<String>,
|
|
pub inference_ms: Option<i64>,
|
|
pub sample_rate: Option<i64>,
|
|
pub audio_channels: Option<i64>,
|
|
pub format_mode: Option<String>,
|
|
pub remove_fillers: bool,
|
|
pub british_english: bool,
|
|
pub anti_hallucination: bool,
|
|
pub created_at: String,
|
|
// Task 2.5 — transcripts_meta (migration v5). Persists the UI metadata
|
|
// that previously lived in the removed localStorage `magnotia_history` cache.
|
|
pub starred: bool,
|
|
pub manual_tags: String,
|
|
pub template: String,
|
|
pub language: String,
|
|
pub segments_json: String,
|
|
/// Phase 9 — comma-joined LLM-generated content tags ("topic:foo",
|
|
/// "intent:planning"). Migration v14. Empty string is the normal
|
|
/// no-tags state.
|
|
pub llm_tags: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct TaskRow {
|
|
pub id: String,
|
|
pub text: String,
|
|
pub bucket: String,
|
|
pub list_id: Option<String>,
|
|
pub effort: Option<String>,
|
|
pub notes: String,
|
|
pub done: bool,
|
|
pub done_at: Option<String>,
|
|
pub created_at: String,
|
|
pub source_transcript_id: Option<String>,
|
|
pub parent_task_id: Option<String>,
|
|
/// Phase 3 energy tagging: one of `"high"`, `"medium"`, `"brain_dead"`,
|
|
/// or `None`. Enforced at the DB layer via a CHECK constraint (see
|
|
/// migration v11). Unset is the expected normal case — the match-my-
|
|
/// energy sort treats unset as Medium-equivalent.
|
|
pub energy: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ImplementationRuleRow {
|
|
pub id: String,
|
|
pub enabled: bool,
|
|
pub trigger_kind: String,
|
|
pub trigger_value: String,
|
|
pub actions_json: String,
|
|
pub last_fired_key: Option<String>,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
}
|
|
|
|
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
|
TranscriptRow {
|
|
id: r.get("id"),
|
|
text: r.get("text"),
|
|
source: r.get("source"),
|
|
profile_id: r.get("profile_id"),
|
|
title: r.get("title"),
|
|
audio_path: r.get("audio_path"),
|
|
duration: r.get("duration"),
|
|
engine: r.get("engine"),
|
|
model_id: r.get("model_id"),
|
|
inference_ms: r.get("inference_ms"),
|
|
sample_rate: r.get("sample_rate"),
|
|
audio_channels: r.get("audio_channels"),
|
|
format_mode: r.get("format_mode"),
|
|
remove_fillers: r.get("remove_fillers"),
|
|
british_english: r.get("british_english"),
|
|
anti_hallucination: r.get("anti_hallucination"),
|
|
created_at: r.get("created_at"),
|
|
// Task 2.5 (migration v5). INTEGER 0/1 → bool via `!= 0`.
|
|
starred: r.get::<i64, _>("starred") != 0,
|
|
manual_tags: r.get("manual_tags"),
|
|
template: r.get("template"),
|
|
language: r.get("language"),
|
|
segments_json: r.get("segments_json"),
|
|
llm_tags: r.get("llm_tags"),
|
|
}
|
|
}
|
|
|
|
fn profile_row_from(r: &sqlx::sqlite::SqliteRow) -> ProfileRow {
|
|
ProfileRow {
|
|
id: r.get("id"),
|
|
name: r.get("name"),
|
|
initial_prompt: r.get("initial_prompt"),
|
|
created_at: r.get("created_at"),
|
|
}
|
|
}
|
|
|
|
fn profile_term_row_from(r: &sqlx::sqlite::SqliteRow) -> ProfileTermRow {
|
|
ProfileTermRow {
|
|
id: r.get("id"),
|
|
profile_id: r.get("profile_id"),
|
|
term: r.get("term"),
|
|
note: r.get("note"),
|
|
created_at: r.get("created_at"),
|
|
}
|
|
}
|
|
|
|
fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
|
|
TaskRow {
|
|
id: r.get("id"),
|
|
text: r.get("text"),
|
|
bucket: r.get("bucket"),
|
|
list_id: r.get("list_id"),
|
|
effort: r.get("effort"),
|
|
notes: r.get("notes"),
|
|
done: r.get("done"),
|
|
done_at: r.get("done_at"),
|
|
created_at: r.get("created_at"),
|
|
source_transcript_id: r.get("source_transcript_id"),
|
|
parent_task_id: r.get("parent_task_id"),
|
|
energy: r.get("energy"),
|
|
}
|
|
}
|
|
|
|
fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRuleRow {
|
|
ImplementationRuleRow {
|
|
id: r.get("id"),
|
|
enabled: r.get::<i64, _>("enabled") != 0,
|
|
trigger_kind: r.get("trigger_kind"),
|
|
trigger_value: r.get("trigger_value"),
|
|
actions_json: r.get("actions_json"),
|
|
last_fired_key: r.get("last_fired_key"),
|
|
created_at: r.get("created_at"),
|
|
updated_at: r.get("updated_at"),
|
|
}
|
|
}
|
|
|
|
// --- Profile CRUD (Phase 2 Task 11) ---
|
|
//
|
|
// Profiles partition the per-user transcription dictionary so that a single
|
|
// database can hold multiple named contexts (work, personal, a client, a
|
|
// project). Each profile owns an `initial_prompt` that steers Whisper's
|
|
// decoder and a list of `profile_terms` that bias recognition and feed
|
|
// post-transcription fix-ups.
|
|
//
|
|
// The `Default` profile (id = DEFAULT_PROFILE_ID, seeded by migration v6)
|
|
// must always exist and must never be renamed. Two layers enforce this:
|
|
// 1. The DB triggers `trg_protect_default_profile_delete` /
|
|
// `trg_protect_default_profile_rename` from migration v6.
|
|
// 2. Rust-layer fail-fast checks below that short-circuit BEFORE the
|
|
// query hits sqlite, so UI callers get a friendly MagnotiaError::StorageError
|
|
// instead of an opaque `SQLITE_CONSTRAINT_TRIGGER` wrapped in text.
|
|
|
|
pub async fn list_profiles(pool: &SqlitePool) -> Result<Vec<ProfileRow>> {
|
|
let rows =
|
|
sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC")
|
|
.fetch_all(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("List profiles failed: {e}")))?;
|
|
Ok(rows.iter().map(profile_row_from).collect())
|
|
}
|
|
|
|
pub async fn get_profile(pool: &SqlitePool, id: &str) -> Result<Option<ProfileRow>> {
|
|
let row = sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles WHERE id = ?")
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Get profile failed: {e}")))?;
|
|
Ok(row.as_ref().map(profile_row_from))
|
|
}
|
|
|
|
pub async fn create_profile(
|
|
pool: &SqlitePool,
|
|
name: &str,
|
|
initial_prompt: &str,
|
|
) -> Result<ProfileRow> {
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
let row = sqlx::query(
|
|
"INSERT INTO profiles (id, name, initial_prompt, created_at) \
|
|
VALUES (?, ?, ?, datetime('now')) \
|
|
RETURNING id, name, initial_prompt, created_at",
|
|
)
|
|
.bind(&id)
|
|
.bind(name)
|
|
.bind(initial_prompt)
|
|
.fetch_one(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Create profile failed: {e}")))?;
|
|
Ok(profile_row_from(&row))
|
|
}
|
|
|
|
/// Full replace of `name` and `initial_prompt` for a profile.
|
|
///
|
|
/// Fails fast in Rust — before hitting sqlite — for two Default-profile
|
|
/// rules that the migration v6 trigger also enforces but returns opaquely:
|
|
/// * the Default id must not be renamed;
|
|
/// * no other profile may be renamed to "Default" (would collide with
|
|
/// the UNIQUE(name) constraint anyway, but we surface a better error).
|
|
pub async fn update_profile(
|
|
pool: &SqlitePool,
|
|
id: &str,
|
|
name: &str,
|
|
initial_prompt: &str,
|
|
) -> Result<()> {
|
|
if id == crate::DEFAULT_PROFILE_ID && name != "Default" {
|
|
return Err(MagnotiaError::StorageError(
|
|
"Default profile cannot be renamed".into(),
|
|
));
|
|
}
|
|
if id != crate::DEFAULT_PROFILE_ID && name == "Default" {
|
|
return Err(MagnotiaError::StorageError(
|
|
"Cannot rename another profile to 'Default'".into(),
|
|
));
|
|
}
|
|
sqlx::query("UPDATE profiles SET name = ?, initial_prompt = ? WHERE id = ?")
|
|
.bind(name)
|
|
.bind(initial_prompt)
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Update profile failed: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a profile. Rejects the Default profile at the Rust layer so the
|
|
/// UI sees a human-readable message instead of a trigger ABORT string.
|
|
/// ON DELETE CASCADE on `profile_terms.profile_id` cleans up children.
|
|
pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> {
|
|
if id == crate::DEFAULT_PROFILE_ID {
|
|
return Err(MagnotiaError::StorageError(
|
|
"Default profile cannot be deleted".into(),
|
|
));
|
|
}
|
|
let transcript_count = transcript_count_for_profile(pool, id).await?;
|
|
if transcript_count > 0 {
|
|
return Err(MagnotiaError::StorageError(format!(
|
|
"Cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first"
|
|
)));
|
|
}
|
|
sqlx::query("DELETE FROM profiles WHERE id = ?")
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Delete profile failed: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_profile_terms(
|
|
pool: &SqlitePool,
|
|
profile_id: &str,
|
|
) -> Result<Vec<ProfileTermRow>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, profile_id, term, note, created_at FROM profile_terms \
|
|
WHERE profile_id = ? ORDER BY term ASC",
|
|
)
|
|
.bind(profile_id)
|
|
.fetch_all(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("List profile terms failed: {e}")))?;
|
|
Ok(rows.iter().map(profile_term_row_from).collect())
|
|
}
|
|
|
|
/// UPSERT on UNIQUE(profile_id, term). Uses ON CONFLICT … DO UPDATE so that
|
|
/// re-adding an existing term simply refreshes the note and returns the
|
|
/// existing row (not a fresh id).
|
|
pub async fn add_profile_term(
|
|
pool: &SqlitePool,
|
|
profile_id: &str,
|
|
term: &str,
|
|
note: &str,
|
|
) -> Result<ProfileTermRow> {
|
|
let new_id = uuid::Uuid::new_v4().to_string();
|
|
let row = sqlx::query(
|
|
"INSERT INTO profile_terms (id, profile_id, term, note, created_at) \
|
|
VALUES (?, ?, ?, ?, datetime('now')) \
|
|
ON CONFLICT(profile_id, term) DO UPDATE SET note = excluded.note \
|
|
RETURNING id, profile_id, term, note, created_at",
|
|
)
|
|
.bind(&new_id)
|
|
.bind(profile_id)
|
|
.bind(term)
|
|
.bind(note)
|
|
.fetch_one(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Add profile term failed: {e}")))?;
|
|
Ok(profile_term_row_from(&row))
|
|
}
|
|
|
|
pub async fn delete_profile_term(pool: &SqlitePool, id: &str) -> Result<()> {
|
|
sqlx::query("DELETE FROM profile_terms WHERE id = ?")
|
|
.bind(id)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Delete profile term failed: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn profile_exists(pool: &SqlitePool, id: &str) -> Result<bool> {
|
|
let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM profiles WHERE id = ? LIMIT 1")
|
|
.bind(id)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Profile existence check failed: {e}")))?;
|
|
Ok(exists.is_some())
|
|
}
|
|
|
|
async fn transcript_count_for_profile(pool: &SqlitePool, id: &str) -> Result<i64> {
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM transcripts WHERE profile_id = ?")
|
|
.bind(id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Profile transcript count failed: {e}")))
|
|
}
|
|
|
|
// --- Error Logging ---
|
|
|
|
/// Log a structured error to the `error_log` table.
|
|
///
|
|
/// Available for Tauri command handlers to persist errors for diagnostics.
|
|
/// Each entry records context (which subsystem), an optional error code,
|
|
/// the human-readable message, and optional JSON metadata.
|
|
///
|
|
/// # Example
|
|
/// ```ignore
|
|
/// log_error(&pool, "transcription", Some("WHISPER_INIT"), "Model load failed", None).await?;
|
|
/// ```
|
|
///
|
|
/// TODO: Wire this into Tauri command error paths so runtime failures are
|
|
/// persisted for user-facing error history and crash diagnostics.
|
|
pub async fn log_error(
|
|
pool: &SqlitePool,
|
|
context: &str,
|
|
error_code: Option<&str>,
|
|
message: &str,
|
|
metadata: Option<&str>,
|
|
) -> Result<()> {
|
|
sqlx::query(
|
|
"INSERT INTO error_log (context, error_code, message, metadata) VALUES (?, ?, ?, ?)",
|
|
)
|
|
.bind(context)
|
|
.bind(error_code)
|
|
.bind(message)
|
|
.bind(metadata)
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Error log failed: {e}")))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the most recent N rows from `error_log`, newest first. Used by the
|
|
/// diagnostic-report bundler in Settings → About.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ErrorLogRow {
|
|
pub id: i64,
|
|
pub timestamp: String,
|
|
pub context: String,
|
|
pub error_code: Option<String>,
|
|
pub message: String,
|
|
pub metadata: Option<String>,
|
|
}
|
|
|
|
/// Delete `error_log` rows older than `keep_days` whole days. Returns the
|
|
/// row count removed.
|
|
///
|
|
/// Called once on app startup so the table doesn't grow unbounded across
|
|
/// months of dogfooding — without this it would eventually balloon the
|
|
/// diagnostic-bundle export and slow the `list_recent_errors` query.
|
|
/// 90 days is the default: long enough to triage a "this happened a few
|
|
/// weeks ago" report, short enough that the table stays kilobyte-scale.
|
|
pub async fn prune_error_log(pool: &SqlitePool, keep_days: i64) -> Result<u64> {
|
|
let result = sqlx::query(
|
|
"DELETE FROM error_log \
|
|
WHERE timestamp < datetime('now', ?)",
|
|
)
|
|
.bind(format!("-{keep_days} days"))
|
|
.execute(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Prune error_log failed: {e}")))?;
|
|
Ok(result.rows_affected())
|
|
}
|
|
|
|
pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result<Vec<ErrorLogRow>> {
|
|
let rows = sqlx::query(
|
|
"SELECT id, timestamp, context, error_code, message, metadata \
|
|
FROM error_log ORDER BY id DESC LIMIT ?",
|
|
)
|
|
.bind(limit)
|
|
.fetch_all(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("Read error_log failed: {e}")))?;
|
|
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|r| ErrorLogRow {
|
|
id: r.get("id"),
|
|
timestamp: r.get("timestamp"),
|
|
context: r.get("context"),
|
|
error_code: r.get("error_code"),
|
|
message: r.get("message"),
|
|
metadata: r.get("metadata"),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
// --- Feedback (HITL) -------------------------------------------------------
|
|
//
|
|
// Phase 2 of the feature-complete roadmap: capture thumbs + corrections on
|
|
// AI-generated output so the prompt builder can inject recent examples as
|
|
// few-shot exemplars. Storage-only here; the prompt-conditioning logic lives
|
|
// in magnotia-llm. Retrieval returns the most recent rows, narrowed to the
|
|
// active profile when provided so feedback does not cross profiles.
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum FeedbackTargetType {
|
|
MicroStep,
|
|
TaskExtraction,
|
|
Cleanup,
|
|
}
|
|
|
|
impl FeedbackTargetType {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
FeedbackTargetType::MicroStep => "microstep",
|
|
FeedbackTargetType::TaskExtraction => "task_extraction",
|
|
FeedbackTargetType::Cleanup => "cleanup",
|
|
}
|
|
}
|
|
|
|
/// Parse the database `target_type` string back into the enum.
|
|
/// Named `parse` rather than `from_str` so it does not collide with
|
|
/// the `std::str::FromStr` trait — the trait is overkill here
|
|
/// because callers never want a `FromStr::Err` and already know the
|
|
/// set of valid values at the call site.
|
|
pub fn parse(s: &str) -> Option<Self> {
|
|
match s {
|
|
"microstep" => Some(FeedbackTargetType::MicroStep),
|
|
"task_extraction" => Some(FeedbackTargetType::TaskExtraction),
|
|
"cleanup" => Some(FeedbackTargetType::Cleanup),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct RecordFeedbackParams {
|
|
pub target_type: FeedbackTargetType,
|
|
pub target_id: Option<String>,
|
|
/// -1 = thumbs down, 0 = correction (neutral), +1 = thumbs up.
|
|
pub rating: i8,
|
|
pub original_text: Option<String>,
|
|
pub corrected_text: Option<String>,
|
|
pub context_json: Option<String>,
|
|
pub profile_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FeedbackRow {
|
|
pub id: i64,
|
|
pub target_type: String,
|
|
pub target_id: Option<String>,
|
|
pub rating: i64,
|
|
pub original_text: Option<String>,
|
|
pub corrected_text: Option<String>,
|
|
pub context_json: Option<String>,
|
|
pub profile_id: String,
|
|
pub created_at: String,
|
|
}
|
|
|
|
pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) -> Result<i64> {
|
|
if !matches!(params.rating, -1..=1) {
|
|
return Err(MagnotiaError::StorageError(format!(
|
|
"invalid feedback rating {} (must be -1, 0, or 1)",
|
|
params.rating
|
|
)));
|
|
}
|
|
let profile_id = params
|
|
.profile_id
|
|
.unwrap_or_else(|| crate::DEFAULT_PROFILE_ID.to_string());
|
|
let row = sqlx::query(
|
|
"INSERT INTO feedback (
|
|
target_type, target_id, rating,
|
|
original_text, corrected_text, context_json, profile_id
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
RETURNING id",
|
|
)
|
|
.bind(params.target_type.as_str())
|
|
.bind(params.target_id)
|
|
.bind(params.rating as i64)
|
|
.bind(params.original_text)
|
|
.bind(params.corrected_text)
|
|
.bind(params.context_json)
|
|
.bind(profile_id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("record_feedback failed: {e}")))?;
|
|
Ok(row.get::<i64, _>("id"))
|
|
}
|
|
|
|
/// Fetch the most recent feedback rows for a given target type, scoped to
|
|
/// the active profile. Used by the prompt builder to gather few-shot
|
|
/// exemplars. Orders by `created_at DESC` so the most recent corrections
|
|
/// outweigh older ones — the user's style drifts, and we want the LLM
|
|
/// to track the current preference.
|
|
///
|
|
/// `min_rating` filters out thumbs-down examples when the caller only
|
|
/// wants positive reinforcement; pass `-1` to include everything.
|
|
pub async fn list_feedback_examples(
|
|
pool: &SqlitePool,
|
|
target_type: FeedbackTargetType,
|
|
limit: i64,
|
|
min_rating: i8,
|
|
profile_id: Option<&str>,
|
|
) -> Result<Vec<FeedbackRow>> {
|
|
let pid = profile_id.unwrap_or(crate::DEFAULT_PROFILE_ID);
|
|
let rows = sqlx::query(
|
|
"SELECT id, target_type, target_id, rating,
|
|
original_text, corrected_text, context_json,
|
|
profile_id, created_at
|
|
FROM feedback
|
|
WHERE target_type = ?
|
|
AND profile_id = ?
|
|
AND rating >= ?
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT ?",
|
|
)
|
|
.bind(target_type.as_str())
|
|
.bind(pid)
|
|
.bind(min_rating as i64)
|
|
.bind(limit)
|
|
.fetch_all(pool)
|
|
.await
|
|
.map_err(|e| MagnotiaError::StorageError(format!("list_feedback_examples failed: {e}")))?;
|
|
|
|
Ok(rows
|
|
.into_iter()
|
|
.map(|r| FeedbackRow {
|
|
id: r.get("id"),
|
|
target_type: r.get("target_type"),
|
|
target_id: r.get("target_id"),
|
|
rating: r.get("rating"),
|
|
original_text: r.get("original_text"),
|
|
corrected_text: r.get("corrected_text"),
|
|
context_json: r.get("context_json"),
|
|
profile_id: r.get("profile_id"),
|
|
created_at: r.get("created_at"),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
async fn test_pool() -> SqlitePool {
|
|
let pool = SqlitePoolOptions::new()
|
|
.max_connections(1)
|
|
.connect("sqlite::memory:")
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("PRAGMA foreign_keys = ON")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
run_migrations(&pool).await.unwrap();
|
|
pool
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn transcript_crud_roundtrip() {
|
|
let pool = test_pool().await;
|
|
|
|
insert_transcript(
|
|
&pool,
|
|
&InsertTranscriptParams {
|
|
id: "t1",
|
|
text: "Hello world",
|
|
source: "microphone",
|
|
profile_id: crate::DEFAULT_PROFILE_ID,
|
|
title: Some("Test"),
|
|
audio_path: None,
|
|
duration: 1.5,
|
|
engine: Some("whisper"),
|
|
model_id: Some("whisper-tiny-en"),
|
|
inference_ms: Some(250),
|
|
sample_rate: Some(48000),
|
|
audio_channels: Some(1),
|
|
format_mode: Some("Clean"),
|
|
remove_fillers: true,
|
|
british_english: true,
|
|
anti_hallucination: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let t = get_transcript(&pool, "t1").await.unwrap().unwrap();
|
|
assert_eq!(t.text, "Hello world");
|
|
assert_eq!(t.source, "microphone");
|
|
assert_eq!(t.profile_id, crate::DEFAULT_PROFILE_ID);
|
|
assert_eq!(t.duration, 1.5);
|
|
assert_eq!(t.engine.as_deref(), Some("whisper"));
|
|
assert_eq!(t.model_id.as_deref(), Some("whisper-tiny-en"));
|
|
assert_eq!(t.inference_ms, Some(250));
|
|
assert!(t.remove_fillers);
|
|
assert!(t.british_english);
|
|
|
|
let list = list_transcripts(&pool, 10).await.unwrap();
|
|
assert_eq!(list.len(), 1);
|
|
|
|
delete_transcript(&pool, "t1").await.unwrap();
|
|
let deleted = get_transcript(&pool, "t1").await.unwrap();
|
|
assert!(deleted.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn search_transcripts_uses_fts5_and_ranks_by_relevance() {
|
|
// Locks in the FTS5 behaviour contract: MATCH-based substring-like
|
|
// search (case-insensitive tokens) with rank-ordered results, so
|
|
// anyone swapping `search_transcripts` out for embeddings later
|
|
// knows what invariants they must preserve.
|
|
let pool = test_pool().await;
|
|
|
|
let rows = [
|
|
("t1", "The Parakeet speech model runs on sherpa-onnx."),
|
|
(
|
|
"t2",
|
|
"Parakeet parakeet parakeet dominates English benchmarks.",
|
|
),
|
|
("t3", "Whisper large-v3 remains the multilingual champion."),
|
|
];
|
|
|
|
for (id, text) in rows {
|
|
insert_transcript(
|
|
&pool,
|
|
&InsertTranscriptParams {
|
|
id,
|
|
text,
|
|
source: "microphone",
|
|
profile_id: crate::DEFAULT_PROFILE_ID,
|
|
title: None,
|
|
audio_path: None,
|
|
duration: 1.0,
|
|
engine: Some("whisper"),
|
|
model_id: Some("whisper-tiny-en"),
|
|
inference_ms: None,
|
|
sample_rate: None,
|
|
audio_channels: None,
|
|
format_mode: None,
|
|
remove_fillers: false,
|
|
british_english: false,
|
|
anti_hallucination: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
let hits = search_transcripts(&pool, "parakeet", 10).await.unwrap();
|
|
let ids: Vec<&str> = hits.iter().map(|row| row.id.as_str()).collect();
|
|
assert_eq!(
|
|
ids,
|
|
vec!["t2", "t1"],
|
|
"t3 has no 'parakeet' token; t2 outranks t1 on repetition"
|
|
);
|
|
|
|
let no_match = search_transcripts(&pool, "moonshine", 10).await.unwrap();
|
|
assert!(no_match.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn task_crud_roundtrip() {
|
|
let pool = test_pool().await;
|
|
|
|
insert_task(
|
|
&pool,
|
|
"task1",
|
|
"Buy groceries",
|
|
"today",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let tasks = list_tasks(&pool).await.unwrap();
|
|
assert_eq!(tasks.len(), 1);
|
|
assert_eq!(tasks[0].text, "Buy groceries");
|
|
assert!(!tasks[0].done);
|
|
|
|
complete_task(&pool, "task1").await.unwrap();
|
|
let tasks = list_tasks(&pool).await.unwrap();
|
|
assert!(tasks[0].done);
|
|
|
|
delete_task(&pool, "task1").await.unwrap();
|
|
let tasks = list_tasks(&pool).await.unwrap();
|
|
assert!(tasks.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn subtask_crud_roundtrip() {
|
|
let pool = test_pool().await;
|
|
insert_task(&pool, "p1", "Write report", "inbox", None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
insert_subtask(&pool, "s1", "Open document", "p1")
|
|
.await
|
|
.unwrap();
|
|
insert_subtask(&pool, "s2", "Write introduction", "p1")
|
|
.await
|
|
.unwrap();
|
|
|
|
// list_tasks must exclude subtasks
|
|
let top_level = list_tasks(&pool).await.unwrap();
|
|
assert_eq!(top_level.len(), 1);
|
|
assert_eq!(top_level[0].id, "p1");
|
|
|
|
// list_subtasks returns both children
|
|
let subs = list_subtasks(&pool, "p1").await.unwrap();
|
|
assert_eq!(subs.len(), 2);
|
|
|
|
// completing s1 should NOT auto-complete parent (s2 still pending)
|
|
complete_subtask_and_check_parent(&pool, "s1")
|
|
.await
|
|
.unwrap();
|
|
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
|
|
assert!(!parent.done, "parent should not be done yet");
|
|
|
|
// completing s2 SHOULD auto-complete parent
|
|
complete_subtask_and_check_parent(&pool, "s2")
|
|
.await
|
|
.unwrap();
|
|
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
|
|
assert!(
|
|
parent.done,
|
|
"parent should auto-complete when all children done"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uncomplete_subtask_reopens_auto_completed_parent() {
|
|
// Regression for the 2026-04-22 review MAJOR on
|
|
// asymmetric complete / uncomplete semantics: once all
|
|
// subtasks completed auto-completes the parent, reopening a
|
|
// child must also reopen the parent so "parent is done iff
|
|
// every child is done" holds in both directions.
|
|
let pool = test_pool().await;
|
|
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
insert_subtask(&pool, "s1", "Final test", "p1")
|
|
.await
|
|
.unwrap();
|
|
insert_subtask(&pool, "s2", "Tag release", "p1")
|
|
.await
|
|
.unwrap();
|
|
|
|
complete_subtask_and_check_parent(&pool, "s1")
|
|
.await
|
|
.unwrap();
|
|
complete_subtask_and_check_parent(&pool, "s2")
|
|
.await
|
|
.unwrap();
|
|
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
|
|
assert!(parent.done);
|
|
|
|
uncomplete_task(&pool, "s2").await.unwrap();
|
|
let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap();
|
|
assert!(
|
|
!parent.done,
|
|
"reopening any child must reopen the auto-completed parent"
|
|
);
|
|
let s2 = get_task_by_id(&pool, "s2").await.unwrap().unwrap();
|
|
assert!(!s2.done, "subtask itself must also be reopened");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uncomplete_top_level_task_does_not_touch_siblings() {
|
|
// Sanity: tasks without a parent must not trigger the parent-
|
|
// reopen branch (it should no-op cleanly).
|
|
let pool = test_pool().await;
|
|
insert_task(&pool, "a", "A", "inbox", None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
insert_task(&pool, "b", "B", "inbox", None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
complete_task(&pool, "a").await.unwrap();
|
|
complete_task(&pool, "b").await.unwrap();
|
|
|
|
uncomplete_task(&pool, "a").await.unwrap();
|
|
|
|
let a = get_task_by_id(&pool, "a").await.unwrap().unwrap();
|
|
let b = get_task_by_id(&pool, "b").await.unwrap().unwrap();
|
|
assert!(!a.done);
|
|
assert!(b.done, "sibling must be untouched");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn implementation_rule_crud_roundtrip() {
|
|
let pool = test_pool().await;
|
|
|
|
let rule = insert_implementation_rule(
|
|
&pool,
|
|
"rule-1",
|
|
true,
|
|
"time_of_day",
|
|
"09:00",
|
|
r#"[{"kind":"speak_line","text":"time to plan the day"}]"#,
|
|
Some("2026-04-24"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(rule.id, "rule-1");
|
|
assert!(rule.enabled);
|
|
assert_eq!(rule.trigger_kind, "time_of_day");
|
|
assert_eq!(rule.trigger_value, "09:00");
|
|
assert_eq!(rule.last_fired_key.as_deref(), Some("2026-04-24"));
|
|
|
|
let rules = list_implementation_rules(&pool).await.unwrap();
|
|
assert_eq!(rules.len(), 1);
|
|
assert_eq!(rules[0].actions_json, rule.actions_json);
|
|
|
|
let disabled = set_implementation_rule_enabled(&pool, "rule-1", false)
|
|
.await
|
|
.unwrap();
|
|
assert!(!disabled.enabled);
|
|
|
|
let fired = mark_implementation_rule_fired(&pool, "rule-1", "2026-04-25")
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(fired.last_fired_key.as_deref(), Some("2026-04-25"));
|
|
|
|
delete_implementation_rule(&pool, "rule-1").await.unwrap();
|
|
assert!(list_implementation_rules(&pool).await.unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_transcript_meta_happy_path() {
|
|
// Task 2.5 — insert a transcript, update starred=true, read it back.
|
|
let pool = test_pool().await;
|
|
insert_transcript(
|
|
&pool,
|
|
&InsertTranscriptParams {
|
|
id: "tm1",
|
|
text: "body",
|
|
source: "microphone",
|
|
profile_id: crate::DEFAULT_PROFILE_ID,
|
|
title: Some("Meta happy"),
|
|
audio_path: None,
|
|
duration: 0.0,
|
|
engine: None,
|
|
model_id: None,
|
|
inference_ms: None,
|
|
sample_rate: None,
|
|
audio_channels: None,
|
|
format_mode: None,
|
|
remove_fillers: false,
|
|
british_english: false,
|
|
anti_hallucination: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let row = update_transcript_meta(&pool, "tm1", Some(true), None, None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
assert!(row.starred, "starred must flip true after update");
|
|
|
|
let fetched = get_transcript(&pool, "tm1").await.unwrap().unwrap();
|
|
assert!(fetched.starred, "starred must persist via SELECT");
|
|
assert_eq!(fetched.manual_tags, "");
|
|
assert_eq!(fetched.template, "");
|
|
assert_eq!(fetched.language, "");
|
|
assert_eq!(fetched.segments_json, "");
|
|
assert_eq!(fetched.llm_tags, "", "llm_tags defaults to empty");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_transcript_meta_partial_leaves_others_unchanged() {
|
|
// Task 2.5 — partial update: only segments_json changes; the other
|
|
// meta columns must be preserved by COALESCE.
|
|
let pool = test_pool().await;
|
|
insert_transcript(
|
|
&pool,
|
|
&InsertTranscriptParams {
|
|
id: "tm2",
|
|
text: "body",
|
|
source: "microphone",
|
|
profile_id: crate::DEFAULT_PROFILE_ID,
|
|
title: None,
|
|
audio_path: None,
|
|
duration: 0.0,
|
|
engine: None,
|
|
model_id: None,
|
|
inference_ms: None,
|
|
sample_rate: None,
|
|
audio_channels: None,
|
|
format_mode: None,
|
|
remove_fillers: false,
|
|
british_english: false,
|
|
anti_hallucination: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Seed starred + tags + template + language first.
|
|
update_transcript_meta(
|
|
&pool,
|
|
"tm2",
|
|
Some(true),
|
|
Some("urgent,review"),
|
|
Some("Meeting"),
|
|
Some("en-GB"),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Now update only segments_json.
|
|
let row = update_transcript_meta(
|
|
&pool,
|
|
"tm2",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some(r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(row.starred, "starred must survive partial update");
|
|
assert_eq!(row.manual_tags, "urgent,review");
|
|
assert_eq!(row.template, "Meeting");
|
|
assert_eq!(row.language, "en-GB");
|
|
assert_eq!(
|
|
row.segments_json,
|
|
r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_transcript_meta_writes_llm_tags() {
|
|
// Phase 9 — llm_tags writes via update_transcript_meta and round-
|
|
// trips via SELECT. Migration v14 default is empty string.
|
|
let pool = test_pool().await;
|
|
insert_transcript(
|
|
&pool,
|
|
&InsertTranscriptParams {
|
|
id: "tm-llm",
|
|
text: "body",
|
|
source: "microphone",
|
|
profile_id: crate::DEFAULT_PROFILE_ID,
|
|
title: None,
|
|
audio_path: None,
|
|
duration: 0.0,
|
|
engine: None,
|
|
model_id: None,
|
|
inference_ms: None,
|
|
sample_rate: None,
|
|
audio_channels: None,
|
|
format_mode: None,
|
|
remove_fillers: false,
|
|
british_english: false,
|
|
anti_hallucination: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let initial = get_transcript(&pool, "tm-llm").await.unwrap().unwrap();
|
|
assert_eq!(initial.llm_tags, "", "default after insert");
|
|
|
|
update_transcript_meta(
|
|
&pool,
|
|
"tm-llm",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some("topic:grant-application,intent:planning"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let fetched = get_transcript(&pool, "tm-llm").await.unwrap().unwrap();
|
|
assert_eq!(fetched.llm_tags, "topic:grant-application,intent:planning",);
|
|
assert_eq!(fetched.manual_tags, "", "manual_tags untouched");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_task_overwrites_provided_fields() {
|
|
// Task 2.6 — happy path: insert, update bucket + effort, read back.
|
|
let pool = test_pool().await;
|
|
insert_task(&pool, "u1", "Draft post", "inbox", None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
let row = update_task(&pool, "u1", None, Some("today"), None, Some("15m"), None)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(row.bucket, "today");
|
|
assert_eq!(row.effort.as_deref(), Some("15m"));
|
|
assert_eq!(row.text, "Draft post", "text must be unchanged");
|
|
assert_eq!(row.notes, "", "notes default must survive");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_task_partial_leaves_others_unchanged() {
|
|
// Task 2.6 — partial update: only notes changes; text/bucket intact.
|
|
let pool = test_pool().await;
|
|
insert_task(
|
|
&pool,
|
|
"u2",
|
|
"Prep slides",
|
|
"today",
|
|
None,
|
|
None,
|
|
Some("30m"),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let row = update_task(
|
|
&pool,
|
|
"u2",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
Some("remember venue wifi"),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(row.text, "Prep slides");
|
|
assert_eq!(row.bucket, "today");
|
|
assert_eq!(row.effort.as_deref(), Some("30m"));
|
|
assert_eq!(row.notes, "remember venue wifi");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_task_missing_id_errors() {
|
|
let pool = test_pool().await;
|
|
let res = update_task(&pool, "missing", Some("x"), None, None, None, None).await;
|
|
assert!(
|
|
res.is_err(),
|
|
"update_task must error when id does not exist"
|
|
);
|
|
}
|
|
|
|
// --- Energy tagging (Phase 3) ---
|
|
|
|
#[tokio::test]
|
|
async fn set_task_energy_round_trip_includes_explicit_clear() {
|
|
let pool = test_pool().await;
|
|
insert_task(&pool, "e1", "Write report", "inbox", None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Fresh task: energy must be NULL.
|
|
let t = get_task_by_id(&pool, "e1").await.unwrap().unwrap();
|
|
assert!(t.energy.is_none(), "new task must start with energy unset");
|
|
|
|
// Set High.
|
|
let t = set_task_energy(&pool, "e1", Some("high")).await.unwrap();
|
|
assert_eq!(t.energy.as_deref(), Some("high"));
|
|
|
|
// Change to Medium.
|
|
let t = set_task_energy(&pool, "e1", Some("medium")).await.unwrap();
|
|
assert_eq!(t.energy.as_deref(), Some("medium"));
|
|
|
|
// Explicit clear back to NULL — the whole reason this function
|
|
// exists separately from update_task's COALESCE semantics.
|
|
let t = set_task_energy(&pool, "e1", None).await.unwrap();
|
|
assert!(
|
|
t.energy.is_none(),
|
|
"set_task_energy(None) must explicitly clear the column"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn set_task_energy_rejects_unknown_value_via_check_constraint() {
|
|
// Migration v11 defines a CHECK constraint; invalid values must
|
|
// be rejected at the DB layer even if a caller bypasses frontend
|
|
// validation.
|
|
let pool = test_pool().await;
|
|
insert_task(&pool, "e2", "Task", "inbox", None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
let res = set_task_energy(&pool, "e2", Some("turbo")).await;
|
|
assert!(
|
|
res.is_err(),
|
|
"CHECK constraint must reject energy values outside the enum"
|
|
);
|
|
}
|
|
|
|
// --- Profile CRUD tests (Task 11) ---
|
|
|
|
#[tokio::test]
|
|
async fn list_profiles_returns_seeded_default() {
|
|
// Happy path: a fresh DB has exactly the seeded Default profile.
|
|
let pool = test_pool().await;
|
|
let profiles = list_profiles(&pool).await.unwrap();
|
|
assert_eq!(profiles.len(), 1, "Default profile must be seeded");
|
|
assert_eq!(profiles[0].id, crate::DEFAULT_PROFILE_ID);
|
|
assert_eq!(profiles[0].name, "Default");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn get_profile_happy_path_and_missing() {
|
|
let pool = test_pool().await;
|
|
let p = get_profile(&pool, crate::DEFAULT_PROFILE_ID).await.unwrap();
|
|
assert!(p.is_some());
|
|
assert_eq!(p.unwrap().name, "Default");
|
|
|
|
// Guardrail: unknown id returns None, not an error.
|
|
let missing = get_profile(&pool, "00000000-0000-0000-0000-000000000099")
|
|
.await
|
|
.unwrap();
|
|
assert!(missing.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_profile_happy_path() {
|
|
let pool = test_pool().await;
|
|
let row = create_profile(&pool, "Work", "You are a meeting notes assistant.")
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(row.name, "Work");
|
|
assert_eq!(row.initial_prompt, "You are a meeting notes assistant.");
|
|
assert!(!row.id.is_empty());
|
|
assert_ne!(row.id, crate::DEFAULT_PROFILE_ID);
|
|
|
|
let profiles = list_profiles(&pool).await.unwrap();
|
|
assert_eq!(profiles.len(), 2, "Default + Work");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_profile_rejects_duplicate_name() {
|
|
// Guardrail: UNIQUE(name) collision surfaces as StorageError.
|
|
let pool = test_pool().await;
|
|
create_profile(&pool, "Personal", "").await.unwrap();
|
|
let res = create_profile(&pool, "Personal", "").await;
|
|
assert!(res.is_err(), "duplicate name must fail");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_profile_happy_path() {
|
|
let pool = test_pool().await;
|
|
let created = create_profile(&pool, "ClientA", "prompt v1").await.unwrap();
|
|
update_profile(&pool, &created.id, "ClientA Renamed", "prompt v2")
|
|
.await
|
|
.unwrap();
|
|
let fetched = get_profile(&pool, &created.id).await.unwrap().unwrap();
|
|
assert_eq!(fetched.name, "ClientA Renamed");
|
|
assert_eq!(fetched.initial_prompt, "prompt v2");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_profile_rejects_renaming_default() {
|
|
// Guardrail: Rust layer rejects renaming the Default id; must not
|
|
// reach sqlite trigger.
|
|
let pool = test_pool().await;
|
|
let res = update_profile(&pool, crate::DEFAULT_PROFILE_ID, "NotDefault", "").await;
|
|
let msg = format!("{}", res.expect_err("must reject rename of Default"));
|
|
assert!(
|
|
msg.contains("Default profile cannot be renamed"),
|
|
"got: {msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_profile_rejects_renaming_other_to_default() {
|
|
// Guardrail: another profile cannot adopt the name "Default".
|
|
let pool = test_pool().await;
|
|
let other = create_profile(&pool, "Temp", "").await.unwrap();
|
|
let res = update_profile(&pool, &other.id, "Default", "").await;
|
|
let msg = format!("{}", res.expect_err("must reject adopting 'Default'"));
|
|
assert!(msg.contains("Default"), "got: {msg}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_profile_happy_path() {
|
|
let pool = test_pool().await;
|
|
let created = create_profile(&pool, "Ephemeral", "").await.unwrap();
|
|
delete_profile(&pool, &created.id).await.unwrap();
|
|
let fetched = get_profile(&pool, &created.id).await.unwrap();
|
|
assert!(fetched.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_profile_rejects_default() {
|
|
let pool = test_pool().await;
|
|
let res = delete_profile(&pool, crate::DEFAULT_PROFILE_ID).await;
|
|
let msg = format!("{}", res.expect_err("must reject delete of Default"));
|
|
assert!(
|
|
msg.contains("Default profile cannot be deleted"),
|
|
"got: {msg}"
|
|
);
|
|
// And Default must still be listed.
|
|
let profiles = list_profiles(&pool).await.unwrap();
|
|
assert_eq!(profiles.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn insert_transcript_rejects_unknown_profile_id() {
|
|
let pool = test_pool().await;
|
|
let err = insert_transcript(
|
|
&pool,
|
|
&InsertTranscriptParams {
|
|
id: "bad-profile",
|
|
text: "Hello",
|
|
source: "microphone",
|
|
profile_id: "profile-missing",
|
|
title: None,
|
|
audio_path: None,
|
|
duration: 0.0,
|
|
engine: None,
|
|
model_id: None,
|
|
inference_ms: None,
|
|
sample_rate: None,
|
|
audio_channels: None,
|
|
format_mode: None,
|
|
remove_fillers: false,
|
|
british_english: false,
|
|
anti_hallucination: false,
|
|
},
|
|
)
|
|
.await
|
|
.expect_err("unknown profile id must be rejected");
|
|
|
|
let msg = err.to_string();
|
|
assert!(msg.contains("unknown profile id"), "got: {msg}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_profile_rejects_when_transcripts_reference_it() {
|
|
let pool = test_pool().await;
|
|
let profile = create_profile(&pool, "Referenced", "").await.unwrap();
|
|
insert_transcript(
|
|
&pool,
|
|
&InsertTranscriptParams {
|
|
id: "referenced-transcript",
|
|
text: "Hello",
|
|
source: "microphone",
|
|
profile_id: &profile.id,
|
|
title: None,
|
|
audio_path: None,
|
|
duration: 0.0,
|
|
engine: None,
|
|
model_id: None,
|
|
inference_ms: None,
|
|
sample_rate: None,
|
|
audio_channels: None,
|
|
format_mode: None,
|
|
remove_fillers: false,
|
|
british_english: false,
|
|
anti_hallucination: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let err = delete_profile(&pool, &profile.id)
|
|
.await
|
|
.expect_err("profile with transcript references must not delete");
|
|
let msg = err.to_string();
|
|
assert!(msg.contains("reassign transcripts first"), "got: {msg}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_profile_terms_happy_path() {
|
|
let pool = test_pool().await;
|
|
let p = create_profile(&pool, "WithTerms", "").await.unwrap();
|
|
add_profile_term(&pool, &p.id, "CORBEL", "company name")
|
|
.await
|
|
.unwrap();
|
|
add_profile_term(&pool, &p.id, "Wren", "agent name")
|
|
.await
|
|
.unwrap();
|
|
let terms = list_profile_terms(&pool, &p.id).await.unwrap();
|
|
assert_eq!(terms.len(), 2);
|
|
// Sorted alphabetically.
|
|
assert_eq!(terms[0].term, "CORBEL");
|
|
assert_eq!(terms[1].term, "Wren");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_profile_terms_filters_by_profile() {
|
|
// Guardrail: terms from one profile must not leak into another.
|
|
let pool = test_pool().await;
|
|
let a = create_profile(&pool, "A", "").await.unwrap();
|
|
let b = create_profile(&pool, "B", "").await.unwrap();
|
|
add_profile_term(&pool, &a.id, "alpha", "").await.unwrap();
|
|
add_profile_term(&pool, &b.id, "beta", "").await.unwrap();
|
|
let a_terms = list_profile_terms(&pool, &a.id).await.unwrap();
|
|
let b_terms = list_profile_terms(&pool, &b.id).await.unwrap();
|
|
assert_eq!(a_terms.len(), 1);
|
|
assert_eq!(a_terms[0].term, "alpha");
|
|
assert_eq!(b_terms.len(), 1);
|
|
assert_eq!(b_terms[0].term, "beta");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_profile_term_happy_path() {
|
|
let pool = test_pool().await;
|
|
let p = create_profile(&pool, "P", "").await.unwrap();
|
|
let row = add_profile_term(&pool, &p.id, "ACME", "client")
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(row.term, "ACME");
|
|
assert_eq!(row.note, "client");
|
|
assert_eq!(row.profile_id, p.id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn add_profile_term_upsert_on_duplicate_term() {
|
|
// Guardrail: UPSERT refreshes note and reuses the existing row id
|
|
// rather than violating UNIQUE(profile_id, term).
|
|
let pool = test_pool().await;
|
|
let p = create_profile(&pool, "P2", "").await.unwrap();
|
|
let first = add_profile_term(&pool, &p.id, "ACME", "first note")
|
|
.await
|
|
.unwrap();
|
|
let second = add_profile_term(&pool, &p.id, "ACME", "second note")
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(first.id, second.id, "UPSERT must preserve row id");
|
|
assert_eq!(second.note, "second note");
|
|
let terms = list_profile_terms(&pool, &p.id).await.unwrap();
|
|
assert_eq!(terms.len(), 1, "UPSERT must not create a second row");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_profile_term_happy_path() {
|
|
let pool = test_pool().await;
|
|
let p = create_profile(&pool, "P3", "").await.unwrap();
|
|
let t = add_profile_term(&pool, &p.id, "Foo", "").await.unwrap();
|
|
delete_profile_term(&pool, &t.id).await.unwrap();
|
|
let remaining = list_profile_terms(&pool, &p.id).await.unwrap();
|
|
assert!(remaining.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_profile_term_missing_id_is_noop() {
|
|
// Guardrail: deleting a non-existent term must not error; sqlite
|
|
// DELETE on zero rows is a success. Verifies we're not hand-rolling
|
|
// an existence check that would regress the UI's happy path on
|
|
// double-click delete.
|
|
let pool = test_pool().await;
|
|
let p = create_profile(&pool, "NoopProfile", "").await.unwrap();
|
|
let t = add_profile_term(&pool, &p.id, "real", "").await.unwrap();
|
|
delete_profile_term(&pool, "00000000-dead-beef-0000-000000000000")
|
|
.await
|
|
.expect("missing id must be a silent no-op");
|
|
// Real term still exists.
|
|
let terms = list_profile_terms(&pool, &p.id).await.unwrap();
|
|
assert_eq!(terms.len(), 1);
|
|
assert_eq!(terms[0].id, t.id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn delete_profile_cascades_to_terms() {
|
|
// Guardrail: deleting a profile must cascade-delete its terms via
|
|
// ON DELETE CASCADE (migration v6 FK).
|
|
let pool = test_pool().await;
|
|
let p = create_profile(&pool, "Doomed", "").await.unwrap();
|
|
add_profile_term(&pool, &p.id, "x", "").await.unwrap();
|
|
add_profile_term(&pool, &p.id, "y", "").await.unwrap();
|
|
delete_profile(&pool, &p.id).await.unwrap();
|
|
// Using a raw query because list_profile_terms would happily
|
|
// return [] even if the FK had been forgotten. We want to prove
|
|
// the child rows are actually gone from the table.
|
|
let count: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM profile_terms WHERE profile_id = ?")
|
|
.bind(&p.id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(count, 0, "terms must cascade-delete with their profile");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn settings_crud_roundtrip() {
|
|
let pool = test_pool().await;
|
|
|
|
set_setting(&pool, "theme", "dark").await.unwrap();
|
|
let val = get_setting(&pool, "theme").await.unwrap();
|
|
assert_eq!(val.as_deref(), Some("dark"));
|
|
|
|
set_setting(&pool, "theme", "light").await.unwrap();
|
|
let val = get_setting(&pool, "theme").await.unwrap();
|
|
assert_eq!(val.as_deref(), Some("light"));
|
|
|
|
let missing = get_setting(&pool, "nonexistent").await.unwrap();
|
|
assert!(missing.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cascade_sets_auto_completed_on_parent_only() {
|
|
let pool = test_pool().await;
|
|
|
|
// Parent + two subtasks.
|
|
insert_task(
|
|
&pool,
|
|
"parent",
|
|
"Parent task",
|
|
"inbox",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
insert_subtask(&pool, "s1", "Step 1", "parent")
|
|
.await
|
|
.unwrap();
|
|
insert_subtask(&pool, "s2", "Step 2", "parent")
|
|
.await
|
|
.unwrap();
|
|
|
|
complete_subtask_and_check_parent(&pool, "s1")
|
|
.await
|
|
.unwrap();
|
|
complete_subtask_and_check_parent(&pool, "s2")
|
|
.await
|
|
.unwrap();
|
|
|
|
// Parent auto-closed.
|
|
let parent_auto: i64 =
|
|
sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 'parent'")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
parent_auto, 1,
|
|
"parent closed by cascade must be auto_completed = 1"
|
|
);
|
|
|
|
// Subtasks themselves are manual.
|
|
let s1_auto: i64 = sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 's1'")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
let s2_auto: i64 = sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 's2'")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(s1_auto, 0, "subtask completion is manual");
|
|
assert_eq!(s2_auto, 0, "subtask completion is manual");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uncomplete_clears_auto_completed_on_parent() {
|
|
let pool = test_pool().await;
|
|
|
|
insert_task(
|
|
&pool,
|
|
"parent",
|
|
"Parent task",
|
|
"inbox",
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
insert_subtask(&pool, "s1", "Only step", "parent")
|
|
.await
|
|
.unwrap();
|
|
|
|
// Cascade closes the parent with auto_completed = 1.
|
|
complete_subtask_and_check_parent(&pool, "s1")
|
|
.await
|
|
.unwrap();
|
|
|
|
// Uncompleting the subtask reopens the parent (existing invariant)
|
|
// and must also clear auto_completed on the parent so a later
|
|
// manual re-completion is counted cleanly.
|
|
uncomplete_task(&pool, "s1").await.unwrap();
|
|
|
|
let parent_done: i64 = sqlx::query_scalar("SELECT done FROM tasks WHERE id = 'parent'")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
let parent_auto: i64 =
|
|
sqlx::query_scalar("SELECT auto_completed FROM tasks WHERE id = 'parent'")
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(parent_done, 0, "parent reopens when child is uncompleted");
|
|
assert_eq!(parent_auto, 0, "auto_completed must clear on reopen");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_recent_completions_returns_n_entries_including_zero_days() {
|
|
let pool = test_pool().await;
|
|
// No completions at all.
|
|
let series = list_recent_completions(&pool, 7).await.unwrap();
|
|
assert_eq!(series.len(), 7, "always returns exactly N entries");
|
|
assert!(series.iter().all(|d| d.count == 0), "all zero for empty DB");
|
|
|
|
// Oldest first, newest last. Dates should be strictly increasing.
|
|
for w in series.windows(2) {
|
|
assert!(w[0].day < w[1].day, "series must be oldest-first");
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_recent_completions_excludes_auto_cascade_parents() {
|
|
let pool = test_pool().await;
|
|
insert_task(&pool, "p", "Parent", "inbox", None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
insert_subtask(&pool, "s1", "Step 1", "p").await.unwrap();
|
|
|
|
// Manual subtask complete. Cascade auto-closes parent.
|
|
complete_subtask_and_check_parent(&pool, "s1")
|
|
.await
|
|
.unwrap();
|
|
|
|
let series = list_recent_completions(&pool, 7).await.unwrap();
|
|
let total: u32 = series.iter().map(|d| d.count).sum();
|
|
assert_eq!(
|
|
total, 1,
|
|
"subtask counts (1), cascade parent excluded. Got series: {series:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_recent_completions_counts_manual_top_level() {
|
|
let pool = test_pool().await;
|
|
insert_task(&pool, "t1", "Task one", "inbox", None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
complete_task(&pool, "t1").await.unwrap();
|
|
|
|
let series = list_recent_completions(&pool, 7).await.unwrap();
|
|
let total: u32 = series.iter().map(|d| d.count).sum();
|
|
assert_eq!(total, 1);
|
|
// Most recent entry is today (local) and holds the 1.
|
|
assert_eq!(series.last().unwrap().count, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_recent_completions_excludes_uncompleted() {
|
|
let pool = test_pool().await;
|
|
insert_task(&pool, "t1", "Task one", "inbox", None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
complete_task(&pool, "t1").await.unwrap();
|
|
uncomplete_task(&pool, "t1").await.unwrap();
|
|
|
|
let series = list_recent_completions(&pool, 7).await.unwrap();
|
|
let total: u32 = series.iter().map(|d| d.count).sum();
|
|
assert_eq!(
|
|
total, 0,
|
|
"uncompleted rows have NULL done_at and must not count"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_recent_completions_uses_local_day_boundary() {
|
|
// Insert two rows with done_at values on either side of local
|
|
// midnight (expressed in UTC, which is how datetime('now') stores).
|
|
// The row whose local-day is "today" must land in today's bucket.
|
|
// The row whose local-day is "yesterday" must land in yesterday's.
|
|
//
|
|
// We avoid hard-coding dates. Compute them at runtime from
|
|
// SQLite so the test is stable across the real clock. The chosen
|
|
// UTC times (2 days ago noon) are safely either side of midnight
|
|
// for any local timezone that matters for this project.
|
|
let pool = test_pool().await;
|
|
|
|
// Seed two tasks that we will manipulate done_at on directly.
|
|
insert_task(&pool, "a", "A", "inbox", None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
insert_task(&pool, "b", "B", "inbox", None, None, None, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Put row "a" at a done_at that is definitely "2 days ago" in
|
|
// the same local-day spine the query uses. Anchor to the local
|
|
// date 2 days ago, then suffix " 12:00:00" so the bucket survives
|
|
// timezone shifts at the query time (noon is far enough from
|
|
// either day boundary). Phase 8's earlier "-2 days, start of
|
|
// day, +12 hours" form mixed UTC `now` with the local-time spine
|
|
// and drifted to "3 days ago" near UTC midnight; this anchor
|
|
// matches the spine directly. Row "b" stays at the current moment.
|
|
sqlx::query(
|
|
"UPDATE tasks SET done = 1, \
|
|
done_at = datetime(DATE('now', 'localtime', '-2 days') || ' 12:00:00'), \
|
|
auto_completed = 0 WHERE id = 'a'",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
sqlx::query(
|
|
"UPDATE tasks SET done = 1, done_at = datetime('now'), auto_completed = 0 \
|
|
WHERE id = 'b'",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
let series = list_recent_completions(&pool, 7).await.unwrap();
|
|
assert_eq!(series.len(), 7);
|
|
|
|
// "today" is the last entry. "2 days ago" is 2 positions before
|
|
// the last entry (0-indexed from end: -1 today, -2 yesterday,
|
|
// -3 two days ago).
|
|
let today_count = series.last().unwrap().count;
|
|
let two_days_ago_count = series[series.len() - 3].count;
|
|
assert_eq!(today_count, 1, "row B is today: {series:?}");
|
|
assert_eq!(two_days_ago_count, 1, "row A is 2 days ago: {series:?}");
|
|
|
|
let total: u32 = series.iter().map(|d| d.count).sum();
|
|
assert_eq!(total, 2, "exactly two completions across the window");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn init_readonly_rejects_writes_and_serves_reads() {
|
|
let dir = std::env::temp_dir().join(format!("magnotia-storage-ro-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("ro.db");
|
|
let _ = std::fs::remove_file(&path);
|
|
|
|
// Seed via the writable path so migrations run.
|
|
let writable = init(&path).await.unwrap();
|
|
insert_transcript(
|
|
&writable,
|
|
&InsertTranscriptParams {
|
|
id: "ro1",
|
|
text: "seed",
|
|
source: "microphone",
|
|
profile_id: crate::DEFAULT_PROFILE_ID,
|
|
title: None,
|
|
audio_path: None,
|
|
duration: 0.0,
|
|
engine: None,
|
|
model_id: None,
|
|
inference_ms: None,
|
|
sample_rate: None,
|
|
audio_channels: None,
|
|
format_mode: None,
|
|
remove_fillers: false,
|
|
british_english: false,
|
|
anti_hallucination: false,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
writable.close().await;
|
|
|
|
// Reopen read-only.
|
|
let ro = init_readonly(&path).await.expect("read-only open");
|
|
assert_eq!(count_transcripts(&ro).await.unwrap(), 1, "reads must work");
|
|
|
|
let write_attempt = sqlx::query("DELETE FROM transcripts WHERE id = 'ro1'")
|
|
.execute(&ro)
|
|
.await;
|
|
assert!(
|
|
write_attempt.is_err(),
|
|
"writes must be rejected on the read-only pool",
|
|
);
|
|
|
|
ro.close().await;
|
|
let _ = std::fs::remove_file(&path);
|
|
let _ = std::fs::remove_dir(&dir);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn init_readonly_fails_when_db_missing() {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"magnotia-storage-ro-missing-{}.db",
|
|
std::process::id()
|
|
));
|
|
let _ = std::fs::remove_file(&path);
|
|
assert!(
|
|
init_readonly(&path).await.is_err(),
|
|
"must fail when DB file does not exist (no create_if_missing)",
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn prune_error_log_removes_old_rows_only() {
|
|
let pool = test_pool().await;
|
|
|
|
// Three rows: one from now, one 30 days old, one 200 days old.
|
|
sqlx::query(
|
|
"INSERT INTO error_log (timestamp, context, error_code, message) VALUES \
|
|
(datetime('now'), 'recent', NULL, 'now'), \
|
|
(datetime('now', '-30 days'), 'older', NULL, '30d'), \
|
|
(datetime('now', '-200 days'), 'oldest', NULL, '200d')",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Default 90-day retention removes only the 200-day row.
|
|
let removed = prune_error_log(&pool, 90).await.unwrap();
|
|
assert_eq!(removed, 1, "only the 200-day row should be pruned");
|
|
|
|
let remaining: Vec<String> = sqlx::query_scalar(
|
|
"SELECT context FROM error_log ORDER BY id ASC",
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(remaining, vec!["recent".to_string(), "older".to_string()]);
|
|
|
|
// A 14-day window prunes the 30-day row too.
|
|
let removed = prune_error_log(&pool, 14).await.unwrap();
|
|
assert_eq!(removed, 1);
|
|
|
|
let remaining: Vec<String> = sqlx::query_scalar(
|
|
"SELECT context FROM error_log",
|
|
)
|
|
.fetch_all(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(remaining, vec!["recent".to_string()]);
|
|
}
|
|
}
|