From 9c3e84d9129071c06533f31ad45b5fffe86dded0 Mon Sep 17 00:00:00 2001 From: NanGua-QWQ Date: Mon, 30 Mar 2026 19:12:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=8A=A0=E5=88=86=E8=A7=84=E5=88=99=E7=AE=A1=E7=90=86=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=A7=84=E5=88=99=E7=9A=84=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E3=80=81=E6=9B=B4=E6=96=B0=E3=80=81=E5=88=A0=E9=99=A4=E5=92=8C?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/auto_score.rs | 312 ++++++++++----------- src-tauri/src/commands/settings.rs | 7 + src-tauri/src/services/auto_score.rs | 391 ++++++++++++++++++++------- src-tauri/src/state.rs | 14 +- src/components/AutoScoreManager.tsx | 16 +- 5 files changed, 490 insertions(+), 250 deletions(-) diff --git a/src-tauri/src/commands/auto_score.rs b/src-tauri/src/commands/auto_score.rs index 4eefffc..8c2e639 100644 --- a/src-tauri/src/commands/auto_score.rs +++ b/src-tauri/src/commands/auto_score.rs @@ -1,10 +1,12 @@ use parking_lot::RwLock; use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; use std::sync::Arc; use tauri::{AppHandle, Emitter, State}; use crate::services::{ - AutoScoreAction, AutoScoreRule, AutoScoreTrigger, PermissionLevel, SettingsKey, SettingsValue, + AutoScoreAction, AutoScoreRule, AutoScoreService, AutoScoreTrigger, PermissionLevel, + SettingsKey, SettingsValue, }; use crate::state::AppState; @@ -51,6 +53,101 @@ pub struct AutoScoreStatus { pub enabled: bool, } +fn build_rule_from_create(rule: CreateAutoScoreRule) -> AutoScoreRule { + AutoScoreRule { + id: 0, + name: rule.name, + enabled: rule.enabled, + student_names: rule.student_names, + triggers: rule.triggers, + actions: rule.actions, + last_executed: None, + } +} + +fn build_rule_from_update(rule: UpdateAutoScoreRule) -> AutoScoreRule { + AutoScoreRule { + id: rule.id, + name: rule.name, + enabled: rule.enabled, + student_names: rule.student_names, + triggers: rule.triggers, + actions: rule.actions, + last_executed: None, + } +} + +async fn load_rules_from_settings( + state: &Arc>, +) -> Result, String> { + let state_guard = state.read(); + let db_conn = state_guard.db.read().clone(); + let mut settings = state_guard.settings.write(); + settings.attach_db(db_conn); + settings.initialize().await?; + + let rules_json = match settings.get_value(SettingsKey::AutoScoreRules) { + SettingsValue::Json(value) => value, + _ => JsonValue::Array(vec![]), + }; + + Ok(AutoScoreService::deserialize_rules(&rules_json)) +} + +async fn persist_rules_to_settings( + state: &Arc>, + rules: &[AutoScoreRule], +) -> Result<(), String> { + let rules_json = AutoScoreService::serialize_rules(rules)?; + let enabled = rules.iter().any(|rule| rule.enabled); + + let state_guard = state.read(); + let db_conn = state_guard.db.read().clone(); + let mut settings = state_guard.settings.write(); + settings.attach_db(db_conn); + settings.initialize().await?; + settings + .set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json)) + .await?; + settings + .set_value( + SettingsKey::AutoScoreEnabled, + SettingsValue::Boolean(enabled), + ) + .await?; + + Ok(()) +} + +fn replace_cached_rules(state: &Arc>, rules: Vec) { + let state_guard = state.read(); + let mut auto_score_service = state_guard.auto_score.write(); + auto_score_service.replace_rules(rules); +} + +async fn sync_cached_rules(state: &Arc>) -> Result, String> { + let rules = load_rules_from_settings(state).await?; + replace_cached_rules(state, rules.clone()); + Ok(rules) +} + +fn emit_rules_changed(app_handle: &AppHandle, state: &Arc>) { + let state_guard = state.read(); + let rules = state_guard.auto_score.read().get_rules().to_vec(); + let _ = app_handle.emit("auto-score:rulesChanged", &rules); +} + +fn emit_auto_score_status_changed(app_handle: &AppHandle, rules: &[AutoScoreRule]) { + let enabled = rules.iter().any(|rule| rule.enabled); + let _ = app_handle.emit( + "settings:changed", + serde_json::json!({ + "key": "auto_score_enabled", + "value": enabled, + }), + ); +} + #[tauri::command] pub async fn auto_score_get_rules( sender_id: Option, @@ -64,9 +161,7 @@ pub async fn auto_score_get_rules( } } - let state_guard = state.read(); - let auto_score_service = state_guard.auto_score.read(); - let rules = auto_score_service.get_rules().to_vec(); + let rules = sync_cached_rules(state.inner()).await?; Ok(IpcResponse::success(rules)) } @@ -85,39 +180,19 @@ pub async fn auto_score_add_rule( } } - let new_id = { - let state_guard = state.read(); - let db_conn = state_guard.db.read().clone(); - let mut auto_score_service = state_guard.auto_score.write(); - let mut settings = state_guard.settings.write(); - settings.attach_db(db_conn); - settings.initialize().await?; - - let new_rule = AutoScoreRule { - id: 0, - name: rule.name, - enabled: rule.enabled, - student_names: rule.student_names, - triggers: rule.triggers, - actions: rule.actions, - last_executed: None, - }; - - let new_id = auto_score_service.add_rule(new_rule); - - let rules_json = auto_score_service.get_rules_json(); - let _ = settings - .set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json)) - .await; - - new_id + let current_rules = sync_cached_rules(state.inner()).await?; + let mut working_service = AutoScoreService::from_rules(current_rules); + let new_id = match working_service.add_rule(build_rule_from_create(rule)) { + Ok(value) => value, + Err(message) => return Ok(IpcResponse::error(&message)), }; - { - let state_guard = state.read(); - let auto_score_service = state_guard.auto_score.read(); - let _ = app_handle.emit("auto-score:rulesChanged", &auto_score_service.get_rules()); - } + let next_rules = working_service.into_rules(); + persist_rules_to_settings(state.inner(), &next_rules).await?; + replace_cached_rules(state.inner(), next_rules); + let rules = state.read().auto_score.read().get_rules().to_vec(); + emit_auto_score_status_changed(&app_handle, &rules); + emit_rules_changed(&app_handle, state.inner()); Ok(IpcResponse::success(new_id)) } @@ -137,47 +212,20 @@ pub async fn auto_score_update_rule( } } - let success = { - let state_guard = state.read(); - let db_conn = state_guard.db.read().clone(); - let mut auto_score_service = state_guard.auto_score.write(); - let mut settings = state_guard.settings.write(); - settings.attach_db(db_conn); - settings.initialize().await?; - - let existing = auto_score_service.get_rule_by_id(rule.id); - let last_executed = existing.and_then(|r| r.last_executed.clone()); - - let updated_rule = AutoScoreRule { - id: rule.id, - name: rule.name, - enabled: rule.enabled, - student_names: rule.student_names, - triggers: rule.triggers, - actions: rule.actions, - last_executed, - }; - - let success = auto_score_service.update_rule(updated_rule); - - if success { - let rules_json = auto_score_service.get_rules_json(); - let _ = settings - .set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json)) - .await; - } - - success - }; - - if success { - let state_guard = state.read(); - let auto_score_service = state_guard.auto_score.read(); - let _ = app_handle.emit("auto-score:rulesChanged", &auto_score_service.get_rules()); - Ok(IpcResponse::success(true)) - } else { - Ok(IpcResponse::error("Rule not found")) + let current_rules = sync_cached_rules(state.inner()).await?; + let mut working_service = AutoScoreService::from_rules(current_rules); + if let Err(message) = working_service.update_rule(build_rule_from_update(rule)) { + return Ok(IpcResponse::error(&message)); } + + let next_rules = working_service.into_rules(); + persist_rules_to_settings(state.inner(), &next_rules).await?; + replace_cached_rules(state.inner(), next_rules); + let rules = state.read().auto_score.read().get_rules().to_vec(); + emit_auto_score_status_changed(&app_handle, &rules); + emit_rules_changed(&app_handle, state.inner()); + + Ok(IpcResponse::success(true)) } #[tauri::command] @@ -195,34 +243,20 @@ pub async fn auto_score_delete_rule( } } - let success = { - let state_guard = state.read(); - let db_conn = state_guard.db.read().clone(); - let mut auto_score_service = state_guard.auto_score.write(); - let mut settings = state_guard.settings.write(); - settings.attach_db(db_conn); - settings.initialize().await?; - - let success = auto_score_service.delete_rule(rule_id); - - if success { - let rules_json = auto_score_service.get_rules_json(); - let _ = settings - .set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json)) - .await; - } - - success - }; - - if success { - let state_guard = state.read(); - let auto_score_service = state_guard.auto_score.read(); - let _ = app_handle.emit("auto-score:rulesChanged", &auto_score_service.get_rules()); - Ok(IpcResponse::success(true)) - } else { - Ok(IpcResponse::error("Rule not found")) + let current_rules = sync_cached_rules(state.inner()).await?; + let mut working_service = AutoScoreService::from_rules(current_rules); + if let Err(message) = working_service.delete_rule(rule_id) { + return Ok(IpcResponse::error(&message)); } + + let next_rules = working_service.into_rules(); + persist_rules_to_settings(state.inner(), &next_rules).await?; + replace_cached_rules(state.inner(), next_rules); + let rules = state.read().auto_score.read().get_rules().to_vec(); + emit_auto_score_status_changed(&app_handle, &rules); + emit_rules_changed(&app_handle, state.inner()); + + Ok(IpcResponse::success(true)) } #[tauri::command] @@ -240,34 +274,20 @@ pub async fn auto_score_toggle_rule( } } - let success = { - let state_guard = state.read(); - let db_conn = state_guard.db.read().clone(); - let mut auto_score_service = state_guard.auto_score.write(); - let mut settings = state_guard.settings.write(); - settings.attach_db(db_conn); - settings.initialize().await?; - - let success = auto_score_service.toggle_rule(params.rule_id, params.enabled); - - if success { - let rules_json = auto_score_service.get_rules_json(); - let _ = settings - .set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json)) - .await; - } - - success - }; - - if success { - let state_guard = state.read(); - let auto_score_service = state_guard.auto_score.read(); - let _ = app_handle.emit("auto-score:rulesChanged", &auto_score_service.get_rules()); - Ok(IpcResponse::success(true)) - } else { - Ok(IpcResponse::error("Rule not found")) + let current_rules = sync_cached_rules(state.inner()).await?; + let mut working_service = AutoScoreService::from_rules(current_rules); + if let Err(message) = working_service.toggle_rule(params.rule_id, params.enabled) { + return Ok(IpcResponse::error(&message)); } + + let next_rules = working_service.into_rules(); + persist_rules_to_settings(state.inner(), &next_rules).await?; + replace_cached_rules(state.inner(), next_rules); + let rules = state.read().auto_score.read().get_rules().to_vec(); + emit_auto_score_status_changed(&app_handle, &rules); + emit_rules_changed(&app_handle, state.inner()); + + Ok(IpcResponse::success(true)) } #[tauri::command] @@ -283,9 +303,8 @@ pub async fn auto_score_get_status( } } - let state_guard = state.read(); - let auto_score_service = state_guard.auto_score.read(); - let enabled = auto_score_service.is_enabled(); + let rules = sync_cached_rules(state.inner()).await?; + let enabled = rules.iter().any(|rule| rule.enabled); Ok(IpcResponse::success(AutoScoreStatus { enabled })) } @@ -304,27 +323,16 @@ pub async fn auto_score_sort_rules( } } - { - let state_guard = state.read(); - let db_conn = state_guard.db.read().clone(); - let mut auto_score_service = state_guard.auto_score.write(); - let mut settings = state_guard.settings.write(); - settings.attach_db(db_conn); - settings.initialize().await?; + let current_rules = sync_cached_rules(state.inner()).await?; + let mut working_service = AutoScoreService::from_rules(current_rules); + working_service.sort_rules(&rule_ids); - auto_score_service.sort_rules(&rule_ids); - - let rules_json = auto_score_service.get_rules_json(); - let _ = settings - .set_value(SettingsKey::AutoScoreRules, SettingsValue::Json(rules_json)) - .await; - } - - { - let state_guard = state.read(); - let auto_score_service = state_guard.auto_score.read(); - let _ = app_handle.emit("auto-score:rulesChanged", &auto_score_service.get_rules()); - } + let next_rules = working_service.into_rules(); + persist_rules_to_settings(state.inner(), &next_rules).await?; + replace_cached_rules(state.inner(), next_rules); + let rules = state.read().auto_score.read().get_rules().to_vec(); + emit_auto_score_status_changed(&app_handle, &rules); + emit_rules_changed(&app_handle, state.inner()); Ok(IpcResponse::success(true)) } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index a8a2b1d..618ecda 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -142,6 +142,13 @@ pub async fn settings_set( .map_err(|e| e.to_string())?; } + if settings_key == SettingsKey::AutoScoreRules { + let state_guard = state.read(); + let mut auto_score = state_guard.auto_score.write(); + auto_score.load_rules(value.clone()); + let _ = app_handle.emit("auto-score:rulesChanged", auto_score.get_rules()); + } + let change = SettingChange { key: key.clone(), value, diff --git a/src-tauri/src/services/auto_score.rs b/src-tauri/src/services/auto_score.rs index bfaefc9..b3dcdf2 100644 --- a/src-tauri/src/services/auto_score.rs +++ b/src-tauri/src/services/auto_score.rs @@ -1,21 +1,24 @@ use chrono::{DateTime, Months, Utc}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use serde_json::Value as JsonValue; +use std::collections::HashSet; use tauri::{AppHandle, Emitter}; -#[derive(Debug, Clone, Serialize, Deserialize)] +const DEFAULT_INTERVAL_MINUTES: i64 = 30; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AutoScoreTrigger { pub event: String, pub value: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AutoScoreAction { pub event: String, pub value: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] enum IntervalUnit { Minute, @@ -23,13 +26,13 @@ enum IntervalUnit { Month, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] struct IntervalTriggerValue { amount: i64, unit: IntervalUnit, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AutoScoreRule { pub id: i32, pub name: String, @@ -75,6 +78,13 @@ impl AutoScoreService { } } + pub fn from_rules(rules: Vec) -> Self { + Self { + rules, + initialized: true, + } + } + pub async fn initialize( &mut self, _app_handle: &AppHandle, @@ -86,96 +96,116 @@ impl AutoScoreService { Ok(()) } - pub fn load_rules(&mut self, rules_json: serde_json::Value) { - if let serde_json::Value::Array(arr) = rules_json { - self.rules = arr - .into_iter() - .filter_map(|v| serde_json::from_value(v).ok()) - .collect(); - } else { - self.rules = Vec::new(); - } + pub fn deserialize_rules(rules_json: &JsonValue) -> Vec { + let JsonValue::Array(items) = rules_json else { + return Vec::new(); + }; + + items + .iter() + .filter_map(|item| serde_json::from_value::(item.clone()).ok()) + .filter_map(|rule| normalize_rule(rule).ok()) + .collect() + } + + pub fn serialize_rules(rules: &[AutoScoreRule]) -> Result { + serde_json::to_value(rules) + .map_err(|error| format!("Failed to serialize auto score rules: {}", error)) + } + + pub fn load_rules(&mut self, rules_json: JsonValue) { + self.rules = Self::deserialize_rules(&rules_json); + } + + pub fn replace_rules(&mut self, rules: Vec) { + self.rules = rules; + } + + pub fn into_rules(self) -> Vec { + self.rules } pub fn get_rules_json(&self) -> serde_json::Value { - serde_json::to_value(&self.rules).unwrap_or(serde_json::Value::Array(vec![])) + Self::serialize_rules(&self.rules).unwrap_or(JsonValue::Array(vec![])) } pub fn get_rules(&self) -> &[AutoScoreRule] { &self.rules } - pub fn get_rules_mut(&mut self) -> &mut Vec { - &mut self.rules + pub fn add_rule(&mut self, rule: AutoScoreRule) -> Result { + let mut normalized_rule = normalize_rule(rule)?; + let new_id = self.rules.iter().map(|item| item.id).max().unwrap_or(0) + 1; + normalized_rule.id = new_id; + normalized_rule.last_executed = None; + self.rules.push(normalized_rule); + Ok(new_id) } - pub fn add_rule(&mut self, mut rule: AutoScoreRule) -> i32 { - let new_id = self.rules.iter().map(|r| r.id).max().unwrap_or(0) + 1; - rule.id = new_id; - rule.last_executed = None; - self.rules.push(rule); - new_id + pub fn update_rule(&mut self, rule: AutoScoreRule) -> Result<(), String> { + let Some(index) = self.rules.iter().position(|item| item.id == rule.id) else { + return Err("Rule not found".to_string()); + }; + + let last_executed = self.rules[index].last_executed.clone(); + let mut normalized_rule = normalize_rule(rule)?; + normalized_rule.id = self.rules[index].id; + normalized_rule.last_executed = normalize_last_executed(last_executed); + self.rules[index] = normalized_rule; + Ok(()) } - pub fn update_rule(&mut self, rule: AutoScoreRule) -> bool { - if let Some(existing) = self.rules.iter_mut().find(|r| r.id == rule.id) { - *existing = rule; - true - } else { - false - } - } - - pub fn delete_rule(&mut self, rule_id: i32) -> bool { + pub fn delete_rule(&mut self, rule_id: i32) -> Result<(), String> { let before_len = self.rules.len(); - self.rules.retain(|r| r.id != rule_id); - self.rules.len() < before_len - } - - pub fn toggle_rule(&mut self, rule_id: i32, enabled: bool) -> bool { - if let Some(rule) = self.rules.iter_mut().find(|r| r.id == rule_id) { - rule.enabled = enabled; - true - } else { - false + self.rules.retain(|rule| rule.id != rule_id); + if self.rules.len() == before_len { + return Err("Rule not found".to_string()); } + Ok(()) } - pub fn sort_rules(&mut self, rule_ids: &[i32]) -> bool { - let rule_map: HashMap = - self.rules.drain(..).map(|r| (r.id, r)).collect(); + pub fn toggle_rule(&mut self, rule_id: i32, enabled: bool) -> Result<(), String> { + let Some(rule) = self.rules.iter_mut().find(|item| item.id == rule_id) else { + return Err("Rule not found".to_string()); + }; - let mut sorted_rules: Vec = Vec::new(); - for id in rule_ids { - if let Some(rule) = rule_map.get(id) { - sorted_rules.push(rule.clone()); + rule.enabled = enabled; + Ok(()) + } + + pub fn sort_rules(&mut self, rule_ids: &[i32]) { + let mut ordered_rules = Vec::with_capacity(self.rules.len()); + let mut consumed = HashSet::new(); + + for rule_id in rule_ids { + if !consumed.insert(*rule_id) { + continue; + } + + if let Some(rule) = self.rules.iter().find(|item| item.id == *rule_id).cloned() { + ordered_rules.push(rule); } } - for (_, rule) in rule_map { - if !rule_ids.contains(&rule.id) { - sorted_rules.push(rule); + for rule in &self.rules { + if consumed.insert(rule.id) { + ordered_rules.push(rule.clone()); } } - self.rules = sorted_rules; - true + self.rules = ordered_rules; } pub fn is_enabled(&self) -> bool { - self.rules.iter().any(|r| r.enabled) + self.rules.iter().any(|rule| rule.enabled) } pub fn get_rule_by_id(&self, id: i32) -> Option<&AutoScoreRule> { - self.rules.iter().find(|r| r.id == id) - } - - pub fn get_rule_by_id_mut(&mut self, id: i32) -> Option<&mut AutoScoreRule> { - self.rules.iter_mut().find(|r| r.id == id) + self.rules.iter().find(|rule| rule.id == id) } pub fn mark_rule_executed(&mut self, rule_id: i32) { - if let Some(rule) = self.rules.iter_mut().find(|r| r.id == rule_id) { + if let Some(rule) = self.rules.iter_mut().find(|item| item.id == rule_id) { rule.last_executed = Some(Utc::now().to_rfc3339()); } } @@ -184,20 +214,29 @@ impl AutoScoreService { let now = Utc::now(); for trigger in &rule.triggers { - if trigger.event == "interval_time_passed" { - let interval = parse_interval_trigger_value(trigger.value.as_ref()); - let base_time = rule - .last_executed - .as_ref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)) - .unwrap_or(now); - - let next_execute_time = add_interval_to_time(base_time, &interval)?; - let delay_ms = (next_execute_time - now).num_milliseconds(); - return Some(delay_ms.max(0)); + if trigger.event != "interval_time_passed" { + continue; } + + let interval = parse_interval_trigger_value(trigger.value.as_deref()).unwrap_or( + IntervalTriggerValue { + amount: DEFAULT_INTERVAL_MINUTES, + unit: IntervalUnit::Minute, + }, + ); + + let base_time = rule + .last_executed + .as_ref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|value| value.with_timezone(&Utc)) + .unwrap_or(now); + + let next_execute_time = add_interval_to_time(base_time, &interval)?; + let delay_ms = (next_execute_time - now).num_milliseconds(); + return Some(delay_ms.max(0)); } + None } @@ -206,27 +245,197 @@ impl AutoScoreService { } } -fn parse_interval_trigger_value(value: Option<&String>) -> IntervalTriggerValue { - if let Some(raw_value) = value { - if let Ok(minutes) = raw_value.parse::() { - if minutes > 0 { - return IntervalTriggerValue { - amount: minutes, - unit: IntervalUnit::Minute, - }; - } +fn normalize_rule(mut rule: AutoScoreRule) -> Result { + rule.name = normalize_required_string(rule.name, "automation name")?; + rule.student_names = dedupe_trimmed_strings(rule.student_names); + + if rule.triggers.is_empty() { + return Err("At least one trigger is required".to_string()); + } + if rule.actions.is_empty() { + return Err("At least one action is required".to_string()); + } + + rule.triggers = rule + .triggers + .into_iter() + .map(normalize_trigger) + .collect::, _>>()?; + rule.actions = rule + .actions + .into_iter() + .map(normalize_action) + .collect::, _>>()?; + rule.last_executed = normalize_last_executed(rule.last_executed); + + Ok(rule) +} + +fn normalize_trigger(trigger: AutoScoreTrigger) -> Result { + let event = normalize_required_string(trigger.event, "trigger event")?; + + match event.as_str() { + "interval_time_passed" => { + let interval = parse_interval_trigger_value(trigger.value.as_deref()) + .ok_or_else(|| "Invalid interval trigger value".to_string())?; + let value = stringify_interval_trigger_value(&interval) + .ok_or_else(|| "Invalid interval trigger value".to_string())?; + + Ok(AutoScoreTrigger { + event, + value: Some(value), + }) + } + "student_has_tag" => { + let tag_values = parse_tag_values(trigger.value.as_deref()); + let value = stringify_tag_values(&tag_values) + .ok_or_else(|| "Student tag trigger requires at least one tag".to_string())?; + + Ok(AutoScoreTrigger { + event, + value: Some(value), + }) + } + _ => Err(format!("Unsupported trigger event: {}", event)), + } +} + +fn normalize_action(action: AutoScoreAction) -> Result { + let event = normalize_required_string(action.event, "action event")?; + + match event.as_str() { + "add_score" => { + let value = normalize_non_zero_numeric_string(action.value.as_deref()) + .ok_or_else(|| "Add score action requires a non-zero numeric value".to_string())?; + + Ok(AutoScoreAction { + event, + value: Some(value), + }) + } + "add_tag" => { + let tag_values = parse_tag_values(action.value.as_deref()); + let value = stringify_tag_values(&tag_values) + .ok_or_else(|| "Add tag action requires at least one tag".to_string())?; + + Ok(AutoScoreAction { + event, + value: Some(value), + }) + } + _ => Err(format!("Unsupported action event: {}", event)), + } +} + +fn normalize_required_string(value: String, field_name: &str) -> Result { + let normalized = value.trim().to_string(); + if normalized.is_empty() { + return Err(format!("{} is required", field_name)); + } + Ok(normalized) +} + +fn dedupe_trimmed_strings(values: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + + for value in values { + let trimmed = value.trim(); + if trimmed.is_empty() { + continue; } - if let Ok(parsed_value) = serde_json::from_str::(raw_value) { - if parsed_value.amount > 0 { - return parsed_value; - } + let owned = trimmed.to_string(); + if seen.insert(owned.clone()) { + normalized.push(owned); } } - IntervalTriggerValue { - amount: 30, - unit: IntervalUnit::Minute, + normalized +} + +fn parse_tag_values(value: Option<&str>) -> Vec { + let Some(raw_value) = value.map(str::trim) else { + return Vec::new(); + }; + + if raw_value.is_empty() { + return Vec::new(); + } + + if raw_value.starts_with('[') { + if let Ok(parsed_values) = serde_json::from_str::>(raw_value) { + return dedupe_trimmed_strings(parsed_values); + } + } + + dedupe_trimmed_strings(vec![raw_value.to_string()]) +} + +fn stringify_tag_values(values: &[String]) -> Option { + if values.is_empty() { + return None; + } + + if values.len() == 1 { + return values.first().cloned(); + } + + serde_json::to_string(values).ok() +} + +fn normalize_non_zero_numeric_string(value: Option<&str>) -> Option { + let normalized = value?.trim(); + if normalized.is_empty() { + return None; + } + + let parsed = normalized.parse::().ok()?; + if !parsed.is_finite() || parsed == 0.0 { + return None; + } + + Some(normalized.to_string()) +} + +fn normalize_last_executed(value: Option) -> Option { + value + .as_deref() + .and_then(|raw_value| DateTime::parse_from_rfc3339(raw_value).ok()) + .map(|parsed| parsed.with_timezone(&Utc).to_rfc3339()) +} + +fn parse_interval_trigger_value(value: Option<&str>) -> Option { + let raw_value = value.map(str::trim)?; + if raw_value.is_empty() { + return None; + } + + if let Ok(minutes) = raw_value.parse::() { + if minutes > 0 { + return Some(IntervalTriggerValue { + amount: minutes, + unit: IntervalUnit::Minute, + }); + } + } + + let parsed_value = serde_json::from_str::(raw_value).ok()?; + if parsed_value.amount <= 0 { + return None; + } + + Some(parsed_value) +} + +fn stringify_interval_trigger_value(value: &IntervalTriggerValue) -> Option { + if value.amount <= 0 { + return None; + } + + match value.unit { + IntervalUnit::Minute => Some(value.amount.to_string()), + IntervalUnit::Day | IntervalUnit::Month => serde_json::to_string(value).ok(), } } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 9798ee5..10eaa5c 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -67,7 +67,19 @@ impl AppState { theme.load_saved_theme(¤t_theme_id); } - self.auto_score.write().initialize(&self.app_handle).await?; + let auto_score_rules = { + let settings = self.settings.read(); + match settings.get_value(SettingsKey::AutoScoreRules) { + SettingsValue::Json(value) => value, + _ => serde_json::Value::Array(vec![]), + } + }; + { + let mut auto_score = self.auto_score.write(); + auto_score.load_rules(auto_score_rules); + auto_score.initialize(&self.app_handle).await?; + } + Ok(()) } } diff --git a/src/components/AutoScoreManager.tsx b/src/components/AutoScoreManager.tsx index cdf527a..b5c8f25 100644 --- a/src/components/AutoScoreManager.tsx +++ b/src/components/AutoScoreManager.tsx @@ -303,7 +303,7 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element { title: t("autoScore.status"), key: "status", - width: 120, + width: 96, render: (_, row) => ( row.studentNames.length > 0 ? ( {t("autoScore.studentCount", { count: row.studentNames.length })} @@ -328,26 +329,27 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element { title: t("autoScore.triggers"), key: "triggers", - width: 150, + width: 120, render: (_, row) => {t("autoScore.triggerCount", { count: row.triggers.length })}, }, { title: t("autoScore.actions"), key: "actions", - width: 140, + width: 120, render: (_, row) => {t("autoScore.actionCount", { count: row.actions.length })}, }, { title: t("autoScore.lastExecuted"), dataIndex: "lastExecuted", key: "lastExecuted", - width: 180, + width: 160, + ellipsis: true, render: (value: string | null | undefined) => formatLastExecuted(value), }, { title: t("common.operation"), key: "operation", - width: 160, + width: 140, render: (_, row) => (