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.
309 lines
9.4 KiB
Rust
309 lines
9.4 KiB
Rust
//! Phase 7 implementation intentions: small if-then automation rules.
|
|
//!
|
|
//! The frontend owns scheduling and execution because the v1 triggers are
|
|
//! app-local events (timer/task/ritual) plus local clock checks. Rust owns
|
|
//! durable rule storage, validation, and main-window-only command access.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use magnotia_storage::{
|
|
delete_implementation_rule as db_delete_rule, get_task_by_id,
|
|
insert_implementation_rule as db_insert_rule, list_implementation_rules as db_list_rules,
|
|
mark_implementation_rule_fired as db_mark_rule_fired,
|
|
set_implementation_rule_enabled as db_set_rule_enabled, ImplementationRuleRow,
|
|
};
|
|
|
|
use crate::commands::security::ensure_main_window;
|
|
use crate::AppState;
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RuleTriggerKind {
|
|
TimeOfDay,
|
|
TaskCompleted,
|
|
MorningTriageFinished,
|
|
}
|
|
|
|
impl RuleTriggerKind {
|
|
fn as_str(&self) -> &'static str {
|
|
match self {
|
|
RuleTriggerKind::TimeOfDay => "time_of_day",
|
|
RuleTriggerKind::TaskCompleted => "task_completed",
|
|
RuleTriggerKind::MorningTriageFinished => "morning_triage_finished",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SurfaceTarget {
|
|
Inbox,
|
|
Today,
|
|
Tasks,
|
|
Task,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum RuleAction {
|
|
Surface {
|
|
target: SurfaceTarget,
|
|
#[serde(default)]
|
|
#[serde(rename = "taskId")]
|
|
task_id: Option<String>,
|
|
#[serde(default)]
|
|
label: Option<String>,
|
|
},
|
|
StartTimer {
|
|
seconds: u32,
|
|
#[serde(default)]
|
|
#[serde(rename = "taskId")]
|
|
task_id: Option<String>,
|
|
#[serde(default)]
|
|
label: Option<String>,
|
|
},
|
|
SpeakLine {
|
|
text: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct CreateImplementationRuleRequest {
|
|
pub trigger_kind: RuleTriggerKind,
|
|
pub trigger_value: String,
|
|
pub actions: Vec<RuleAction>,
|
|
#[serde(default = "default_enabled")]
|
|
pub enabled: bool,
|
|
#[serde(default)]
|
|
pub last_fired_key: Option<String>,
|
|
}
|
|
|
|
fn default_enabled() -> bool {
|
|
true
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ImplementationRuleDto {
|
|
pub id: String,
|
|
pub enabled: bool,
|
|
pub trigger_kind: RuleTriggerKind,
|
|
pub trigger_value: String,
|
|
pub actions: Vec<RuleAction>,
|
|
pub last_fired_key: Option<String>,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
}
|
|
|
|
impl TryFrom<ImplementationRuleRow> for ImplementationRuleDto {
|
|
type Error = String;
|
|
|
|
fn try_from(row: ImplementationRuleRow) -> Result<Self, Self::Error> {
|
|
let trigger_kind = match row.trigger_kind.as_str() {
|
|
"time_of_day" => RuleTriggerKind::TimeOfDay,
|
|
"task_completed" => RuleTriggerKind::TaskCompleted,
|
|
"morning_triage_finished" => RuleTriggerKind::MorningTriageFinished,
|
|
other => return Err(format!("unknown rule trigger kind: {other}")),
|
|
};
|
|
let actions = serde_json::from_str::<Vec<RuleAction>>(&row.actions_json)
|
|
.map_err(|e| format!("invalid rule actions JSON for {}: {e}", row.id))?;
|
|
Ok(Self {
|
|
id: row.id,
|
|
enabled: row.enabled,
|
|
trigger_kind,
|
|
trigger_value: row.trigger_value,
|
|
actions,
|
|
last_fired_key: row.last_fired_key,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn validate_hhmm(value: &str) -> Result<(), String> {
|
|
let (h, m) = value
|
|
.split_once(':')
|
|
.ok_or_else(|| "time rules need HH:MM".to_string())?;
|
|
if h.len() != 2 || m.len() != 2 {
|
|
return Err("time rules need HH:MM".into());
|
|
}
|
|
let hour: u8 = h
|
|
.parse()
|
|
.map_err(|_| "time hour must be 00-23".to_string())?;
|
|
let minute: u8 = m
|
|
.parse()
|
|
.map_err(|_| "time minute must be 00-59".to_string())?;
|
|
if hour > 23 || minute > 59 {
|
|
return Err("time must be between 00:00 and 23:59".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn validate_actions(
|
|
state: &tauri::State<'_, AppState>,
|
|
actions: &[RuleAction],
|
|
) -> Result<(), String> {
|
|
if actions.is_empty() {
|
|
return Err("add at least one rule action".into());
|
|
}
|
|
for action in actions {
|
|
match action {
|
|
RuleAction::Surface {
|
|
target,
|
|
task_id,
|
|
label: _,
|
|
} => {
|
|
if matches!(target, SurfaceTarget::Task) {
|
|
let task_id = task_id
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|id| !id.is_empty())
|
|
.ok_or_else(|| "surface-task actions need a task".to_string())?;
|
|
let exists = get_task_by_id(&state.db, task_id)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.is_some();
|
|
if !exists {
|
|
return Err(format!("task {task_id} does not exist"));
|
|
}
|
|
}
|
|
}
|
|
RuleAction::StartTimer { seconds, .. } => {
|
|
if *seconds != 300 {
|
|
return Err("v1 rule timers are fixed at 5 minutes".into());
|
|
}
|
|
}
|
|
RuleAction::SpeakLine { text } => {
|
|
let trimmed = text.trim();
|
|
if trimmed.is_empty() {
|
|
return Err("speak actions need a line to read".into());
|
|
}
|
|
if trimmed.chars().count() > 240 {
|
|
return Err("speak lines must be 240 characters or fewer".into());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn validate_request(
|
|
state: &tauri::State<'_, AppState>,
|
|
request: &CreateImplementationRuleRequest,
|
|
) -> Result<(), String> {
|
|
match request.trigger_kind {
|
|
RuleTriggerKind::TimeOfDay => validate_hhmm(request.trigger_value.trim())?,
|
|
RuleTriggerKind::TaskCompleted | RuleTriggerKind::MorningTriageFinished => {
|
|
if !request.trigger_value.trim().is_empty() {
|
|
return Err("this trigger does not take an extra value".into());
|
|
}
|
|
}
|
|
}
|
|
validate_actions(state, &request.actions).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn list_implementation_rules(
|
|
window: tauri::WebviewWindow,
|
|
state: tauri::State<'_, AppState>,
|
|
) -> Result<Vec<ImplementationRuleDto>, String> {
|
|
ensure_main_window(&window)?;
|
|
db_list_rules(&state.db)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.into_iter()
|
|
.map(ImplementationRuleDto::try_from)
|
|
.collect()
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn create_implementation_rule(
|
|
window: tauri::WebviewWindow,
|
|
state: tauri::State<'_, AppState>,
|
|
request: CreateImplementationRuleRequest,
|
|
) -> Result<ImplementationRuleDto, String> {
|
|
ensure_main_window(&window)?;
|
|
validate_request(&state, &request).await?;
|
|
|
|
let id = Uuid::new_v4().to_string();
|
|
let actions_json = serde_json::to_string(&request.actions)
|
|
.map_err(|e| format!("serialise rule actions failed: {e}"))?;
|
|
let trigger_value = match request.trigger_kind {
|
|
RuleTriggerKind::TimeOfDay => request.trigger_value.trim().to_string(),
|
|
RuleTriggerKind::TaskCompleted | RuleTriggerKind::MorningTriageFinished => String::new(),
|
|
};
|
|
|
|
db_insert_rule(
|
|
&state.db,
|
|
&id,
|
|
request.enabled,
|
|
request.trigger_kind.as_str(),
|
|
&trigger_value,
|
|
&actions_json,
|
|
request.last_fired_key.as_deref(),
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
.and_then(ImplementationRuleDto::try_from)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn set_implementation_rule_enabled(
|
|
window: tauri::WebviewWindow,
|
|
state: tauri::State<'_, AppState>,
|
|
id: String,
|
|
enabled: bool,
|
|
) -> Result<ImplementationRuleDto, String> {
|
|
ensure_main_window(&window)?;
|
|
db_set_rule_enabled(&state.db, &id, enabled)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
.and_then(ImplementationRuleDto::try_from)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn mark_implementation_rule_fired(
|
|
window: tauri::WebviewWindow,
|
|
state: tauri::State<'_, AppState>,
|
|
id: String,
|
|
fired_key: String,
|
|
) -> Result<ImplementationRuleDto, String> {
|
|
ensure_main_window(&window)?;
|
|
let key = fired_key.trim();
|
|
if key.is_empty() {
|
|
return Err("fired_key is required".into());
|
|
}
|
|
db_mark_rule_fired(&state.db, &id, key)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
.and_then(ImplementationRuleDto::try_from)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn delete_implementation_rule(
|
|
window: tauri::WebviewWindow,
|
|
state: tauri::State<'_, AppState>,
|
|
id: String,
|
|
) -> Result<(), String> {
|
|
ensure_main_window(&window)?;
|
|
db_delete_rule(&state.db, &id)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::validate_hhmm;
|
|
|
|
#[test]
|
|
fn hhmm_validation_accepts_and_rejects_expected_shapes() {
|
|
assert!(validate_hhmm("09:00").is_ok());
|
|
assert!(validate_hhmm("23:59").is_ok());
|
|
assert!(validate_hhmm("9:00").is_err());
|
|
assert!(validate_hhmm("24:00").is_err());
|
|
assert!(validate_hhmm("08:60").is_err());
|
|
}
|
|
}
|