diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..cdacd44
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,15 @@
+# Changelog
+
+All notable changes to this project are documented in this file.
+
+## [Unreleased]
+
+### Changed
+- `SettingsValidator`: removed stale `TODO Reimplement in Options` comment; the validator is already wired into `OptionsForm.ValidateSettings`.
+- `TaskRunner.ParseParameters`: now delegates to a new testable `LiteTask.ParameterParser` module that supports cmd-style quoting (`key="value with spaces"`) while remaining backward-compatible with `key=value`.
+
+### Added
+- `SettingsValidator.ValidateTaskAction(action[, siblings])`: validates name/target presence, target file existence for PowerShell/Batch/Executable types, non-negative retry parameters, timeout >= 1, and `DependsOn` referencing a sibling action's `Name`.
+- `ActionDialog` and `TaskForm` now run candidate actions through `ValidateTaskAction` on save; cross-action `DependsOn` consistency is checked at the task level.
+- `Tests/LiteTask.Tests.vbproj`: MSTest project covering `ParameterParser` (11 cases: basic form, quoted spaces, mixed quoting, empty values, dropped tokens, duplicate keys, whitespace tolerance).
+- French translations for the new `Validation.*` keys in `LiteTaskData/lang/fr.xml`.
diff --git a/Class/ParameterParser.vb b/Class/ParameterParser.vb
new file mode 100644
index 0000000..779fa98
--- /dev/null
+++ b/Class/ParameterParser.vb
@@ -0,0 +1,92 @@
+' -----------------------------------------------------------------------------
+' Copyright (c) svtica. All rights reserved.
+' File: ParameterParser.vb
+' Author: LiteTask contributors
+' Date: 2026-04-25
+' Purpose: Parse "key=value" parameter strings used by TaskRunner. Supports
+' cmd-style quoting (key="value with spaces") while remaining
+' backward-compatible with the original whitespace-delimited form.
+' -----------------------------------------------------------------------------
+Namespace LiteTask
+ Public Module ParameterParser
+
+ '''
+ ''' Parses a parameter string of the form `key=value [key2=value2 ...]`.
+ ''' Values may be quoted with double quotes to include spaces:
+ ''' key="value with spaces"
+ ''' Tokens without an `=` are skipped, as are `=value` fragments.
+ ''' Last occurrence wins for duplicate keys.
+ '''
+ Public Function Parse(parameters As String) As Dictionary(Of String, String)
+ Dim result As New Dictionary(Of String, String)
+ If String.IsNullOrEmpty(parameters) Then Return result
+
+ Dim i As Integer = 0
+ Dim len As Integer = parameters.Length
+
+ While i < len
+ ' Skip leading whitespace
+ While i < len AndAlso Char.IsWhiteSpace(parameters(i))
+ i += 1
+ End While
+ If i >= len Then Exit While
+
+ ' Read key: up to '=' or whitespace
+ Dim keyStart As Integer = i
+ While i < len AndAlso parameters(i) <> "="c AndAlso Not Char.IsWhiteSpace(parameters(i))
+ i += 1
+ End While
+
+ ' No '=' for this token: skip token entirely (preserves original behavior
+ ' where bare words without '=' were dropped).
+ If i >= len OrElse parameters(i) <> "="c Then
+ Continue While
+ End If
+
+ Dim key As String = parameters.Substring(keyStart, i - keyStart)
+ i += 1 ' consume '='
+
+ ' Empty key (`=value`) is skipped to match original behavior.
+ If String.IsNullOrEmpty(key) Then
+ ' Advance past the value so we don't reparse it as a new token
+ SkipValue(parameters, i)
+ Continue While
+ End If
+
+ Dim value As String = ReadValue(parameters, i)
+ result(key) = value
+ End While
+
+ Return result
+ End Function
+
+ Private Function ReadValue(s As String, ByRef i As Integer) As String
+ Dim len As Integer = s.Length
+ If i >= len Then Return String.Empty
+
+ If s(i) = """"c Then
+ ' Quoted value: read until next '"' or end of string.
+ i += 1
+ Dim valueStart As Integer = i
+ While i < len AndAlso s(i) <> """"c
+ i += 1
+ End While
+ Dim value As String = s.Substring(valueStart, i - valueStart)
+ If i < len Then i += 1 ' consume closing quote
+ Return value
+ End If
+
+ ' Unquoted value: read until whitespace.
+ Dim unquotedStart As Integer = i
+ While i < len AndAlso Not Char.IsWhiteSpace(s(i))
+ i += 1
+ End While
+ Return s.Substring(unquotedStart, i - unquotedStart)
+ End Function
+
+ Private Sub SkipValue(s As String, ByRef i As Integer)
+ ReadValue(s, i)
+ End Sub
+
+ End Module
+End Namespace
diff --git a/Class/SettingsValidator.vb b/Class/SettingsValidator.vb
index 03e5999..3db66e4 100644
--- a/Class/SettingsValidator.vb
+++ b/Class/SettingsValidator.vb
@@ -1,7 +1,8 @@
Namespace LiteTask
Public Class SettingsValidator
- 'TODO Reimplement in Options
+ ' Wired into OptionsForm.ValidateSettings (Forms/OptionsForm.vb), called from
+ ' SaveSettings, OnFormClosing, and TestEmailButton_Click.
Private ReadOnly _logger As Logger
@@ -98,6 +99,77 @@ Namespace LiteTask
Return errors
End Function
+ Public Function ValidateTaskAction(action As TaskAction) As List(Of String)
+ Return ValidateTaskAction(action, Nothing)
+ End Function
+
+ ' Validates a single TaskAction. When siblingActions is provided, DependsOn
+ ' is checked against the Names in that collection (same-task semantics, see
+ ' TaskDependencyManager). PowerShell modules are intentionally not preflighted.
+ Public Function ValidateTaskAction(action As TaskAction, siblingActions As IEnumerable(Of TaskAction)) As List(Of String)
+ Dim errors As New List(Of String)
+
+ Try
+ If action Is Nothing Then
+ errors.Add("Action is null")
+ Return errors
+ End If
+
+ If String.IsNullOrWhiteSpace(action.Name) Then
+ errors.Add("Action name is required")
+ End If
+
+ If String.IsNullOrWhiteSpace(action.Target) Then
+ errors.Add("Action target is required")
+ Else
+ Select Case action.Type
+ Case ScheduledTask.TaskType.PowerShell,
+ ScheduledTask.TaskType.Batch,
+ ScheduledTask.TaskType.Executable
+ If Not File.Exists(action.Target) Then
+ errors.Add($"Target file does not exist: {action.Target}")
+ End If
+ Case ScheduledTask.TaskType.SQL
+ ' Target holds the SQL string or path; require non-empty (already covered above)
+ Case ScheduledTask.TaskType.RemoteExecution
+ ' Target is a remote endpoint or script path; non-empty is sufficient here
+ End Select
+ End If
+
+ If action.TimeoutMinutes < 1 Then
+ errors.Add("Timeout must be at least 1 minute")
+ End If
+
+ If action.RetryCount < 0 Then
+ errors.Add("Retry count cannot be negative")
+ End If
+
+ If action.RetryDelayMinutes < 0 Then
+ errors.Add("Retry delay cannot be negative")
+ End If
+
+ If Not String.IsNullOrWhiteSpace(action.DependsOn) AndAlso siblingActions IsNot Nothing Then
+ Dim selfName = If(action.Name, String.Empty)
+ Dim known = siblingActions _
+ .Where(Function(a) a IsNot Nothing AndAlso Not Object.ReferenceEquals(a, action)) _
+ .Select(Function(a) a.Name) _
+ .Where(Function(n) Not String.IsNullOrWhiteSpace(n))
+
+ If String.Equals(action.DependsOn, selfName, StringComparison.OrdinalIgnoreCase) Then
+ errors.Add($"Action '{selfName}' cannot depend on itself")
+ ElseIf Not known.Any(Function(n) String.Equals(n, action.DependsOn, StringComparison.OrdinalIgnoreCase)) Then
+ errors.Add($"DependsOn references unknown action: {action.DependsOn}")
+ End If
+ End If
+
+ Catch ex As Exception
+ _logger?.LogError("Error validating task action", ex)
+ errors.Add($"Error validating task action: {ex.Message}")
+ End Try
+
+ Return errors
+ End Function
+
Private Function IsValidEmail(email As String) As Boolean
Try
Dim addr = New System.Net.Mail.MailAddress(email)
diff --git a/Class/TaskRunner.vb b/Class/TaskRunner.vb
index b805cb8..4c509f1 100644
--- a/Class/TaskRunner.vb
+++ b/Class/TaskRunner.vb
@@ -803,26 +803,13 @@ Namespace LiteTask
Private Function ParseParameters(parameters As String) As Dictionary(Of String, String)
- Dim result As New Dictionary(Of String, String)
Try
- If String.IsNullOrEmpty(parameters) Then Return result
-
- ' Use Span for more efficient string splitting (if available) or stick with optimized approach
- Dim _paramArray = parameters.Split(New Char() {" "c}, StringSplitOptions.RemoveEmptyEntries)
- For Each param In _paramArray
- Dim equalIndex = param.IndexOf("="c)
- If equalIndex > 0 AndAlso equalIndex < param.Length - 1 Then
- Dim key = param.Substring(0, equalIndex)
- Dim value = param.Substring(equalIndex + 1)
- result(key) = value
- End If
- Next
-
+ Dim result = ParameterParser.Parse(parameters)
_logger.LogInfo($"Parsed {result.Count} parameters successfully")
Return result
Catch ex As Exception
_logger.LogError($"Error parsing parameters: {ex.Message}")
- Return result
+ Return New Dictionary(Of String, String)
End Try
End Function
diff --git a/Forms/ActionDialog.vb b/Forms/ActionDialog.vb
index 4f956d1..7ee0977 100644
--- a/Forms/ActionDialog.vb
+++ b/Forms/ActionDialog.vb
@@ -212,13 +212,31 @@ Namespace LiteTask
End Sub
Private Function ValidateInput() As Boolean
- If String.IsNullOrWhiteSpace(_nameTextBox.Text) Then
- MessageBox.Show("Please enter a name for the action.", "Validation Error", MessageBoxButtons.OK, MessageBoxIcon.Warning)
- Return False
- End If
-
- If String.IsNullOrWhiteSpace(_targetTextBox.Text) Then
- MessageBox.Show("Please specify a target.", "Validation Error", MessageBoxButtons.OK, MessageBoxIcon.Warning)
+ ' Build a transient TaskAction reflecting the current form state and run
+ ' it through SettingsValidator so the UI and persisted-task validation
+ ' share a single rule set.
+ Dim candidate As New TaskAction With {
+ .Name = _nameTextBox.Text.Trim(),
+ .Type = If(_typeComboBox.SelectedItem Is Nothing,
+ ScheduledTask.TaskType.PowerShell,
+ CType([Enum].Parse(GetType(ScheduledTask.TaskType), _typeComboBox.SelectedItem.ToString()), ScheduledTask.TaskType)),
+ .Target = _targetTextBox.Text.Trim(),
+ .Parameters = _parametersTextBox.Text.Trim(),
+ .DependsOn = If(_dependsOnCombo.SelectedIndex > 0, _dependsOnCombo.SelectedItem.ToString(), Nothing),
+ .WaitForCompletion = _waitForCompletionCheck.Checked,
+ .TimeoutMinutes = Convert.ToInt32(_timeoutNumeric.Value),
+ .RetryCount = Convert.ToInt32(_retryCountNumeric.Value),
+ .RetryDelayMinutes = Convert.ToInt32(_retryDelayNumeric.Value),
+ .ContinueOnError = _continueOnErrorCheck.Checked
+ }
+
+ Dim validator As New SettingsValidator(_logger)
+ Dim errors = validator.ValidateTaskAction(candidate)
+
+ If errors.Any() Then
+ MessageBox.Show(String.Join(Environment.NewLine, errors),
+ TranslationManager.Instance.GetTranslation("Validation.Error", "Validation Error"),
+ MessageBoxButtons.OK, MessageBoxIcon.Warning)
Return False
End If
diff --git a/Forms/TaskForm.vb b/Forms/TaskForm.vb
index 0da0ea4..573f9c6 100644
--- a/Forms/TaskForm.vb
+++ b/Forms/TaskForm.vb
@@ -317,6 +317,11 @@ Namespace LiteTask
UpdateTaskFromForm()
+ If Not ValidateTaskActions() Then
+ DialogResult = DialogResult.None
+ Return
+ End If
+
If _isEditMode Then
_customScheduler.UpdateTask(_task)
Else
@@ -333,6 +338,31 @@ Namespace LiteTask
End Try
End Sub
+ Private Function ValidateTaskActions() As Boolean
+ If _task Is Nothing OrElse _task.Actions Is Nothing OrElse _task.Actions.Count = 0 Then
+ Return True
+ End If
+
+ Dim validator As New SettingsValidator(_logger)
+ Dim allErrors As New List(Of String)
+
+ For Each action In _task.Actions
+ Dim actionErrors = validator.ValidateTaskAction(action, _task.Actions)
+ For Each actionError In actionErrors
+ allErrors.Add($"[{If(action.Name, "(unnamed)")}] {actionError}")
+ Next
+ Next
+
+ If allErrors.Any() Then
+ MessageBox.Show(String.Join(Environment.NewLine, allErrors),
+ TranslationManager.Instance.GetTranslation("Validation.Error", "Validation Error"),
+ MessageBoxButtons.OK, MessageBoxIcon.Warning)
+ Return False
+ End If
+
+ Return True
+ End Function
+
Private Sub RecurrenceTypeCombo_SelectedIndexChanged(sender As Object, e As EventArgs)
UpdateRecurrenceControls()
End Sub
diff --git a/LiteTask.sln b/LiteTask.sln
index 5080164..5153adf 100644
--- a/LiteTask.sln
+++ b/LiteTask.sln
@@ -5,6 +5,8 @@ VisualStudioVersion = 17.11.35208.52
MinimumVisualStudioVersion = 10.0.40219.1
Project("{778DAE3C-4631-46EA-AA77-85C1314464D9}") = "LiteTask", "LiteTask.vbproj", "{1A0A6447-8B40-47AE-9DA4-F9B3BF40397D}"
EndProject
+Project("{778DAE3C-4631-46EA-AA77-85C1314464D9}") = "LiteTask.Tests", "Tests\LiteTask.Tests.vbproj", "{B3C5D7E9-F124-4A6B-8C9D-1E2F3A4B5C6D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -21,6 +23,14 @@ Global
{1A0A6447-8B40-47AE-9DA4-F9B3BF40397D}.Release|Any CPU.Build.0 = Release|Any CPU
{1A0A6447-8B40-47AE-9DA4-F9B3BF40397D}.Release|x64.ActiveCfg = Release|x64
{1A0A6447-8B40-47AE-9DA4-F9B3BF40397D}.Release|x64.Build.0 = Release|x64
+ {B3C5D7E9-F124-4A6B-8C9D-1E2F3A4B5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B3C5D7E9-F124-4A6B-8C9D-1E2F3A4B5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B3C5D7E9-F124-4A6B-8C9D-1E2F3A4B5C6D}.Debug|x64.ActiveCfg = Debug|x64
+ {B3C5D7E9-F124-4A6B-8C9D-1E2F3A4B5C6D}.Debug|x64.Build.0 = Debug|x64
+ {B3C5D7E9-F124-4A6B-8C9D-1E2F3A4B5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B3C5D7E9-F124-4A6B-8C9D-1E2F3A4B5C6D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B3C5D7E9-F124-4A6B-8C9D-1E2F3A4B5C6D}.Release|x64.ActiveCfg = Release|x64
+ {B3C5D7E9-F124-4A6B-8C9D-1E2F3A4B5C6D}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/LiteTask.vbproj b/LiteTask.vbproj
index f99d5ab..74a33c4 100644
--- a/LiteTask.vbproj
+++ b/LiteTask.vbproj
@@ -76,14 +76,17 @@
+
+
+
diff --git a/LiteTaskData/lang/fr.xml b/LiteTaskData/lang/fr.xml
index b5b0528..22ca7a6 100644
--- a/LiteTaskData/lang/fr.xml
+++ b/LiteTaskData/lang/fr.xml
@@ -393,4 +393,15 @@
Redémarrer le service quotidiennement pour récupérer la mémoire et les handles. Le redémarrage est ignoré si une tâche est en cours ou planifiée dans les 5 prochaines minutes.
Heure du redémarrage quotidien du service (par défaut : 03h00)
Envoyer une notification par courriel lors du redémarrage quotidien
+
+
+ Erreur de validation
+ Le nom de l'action est requis
+ La cible de l'action est requise
+ Le fichier cible n'existe pas : {0}
+ Le délai d'expiration doit être d'au moins 1 minute
+ Le nombre de tentatives ne peut pas être négatif
+ Le délai entre tentatives ne peut pas être négatif
+ L'action '{0}' ne peut pas dépendre d'elle-même
+ DependsOn référence une action inconnue : {0}
\ No newline at end of file
diff --git a/Tests/LiteTask.Tests.vbproj b/Tests/LiteTask.Tests.vbproj
new file mode 100644
index 0000000..8eab277
--- /dev/null
+++ b/Tests/LiteTask.Tests.vbproj
@@ -0,0 +1,22 @@
+
+
+ net8.0-windows8.0
+ true
+ LiteTask.Tests
+ false
+ true
+ true
+ AnyCPU;x64
+ $(NoWarn);BC42021
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Tests/ParameterParserTests.vb b/Tests/ParameterParserTests.vb
new file mode 100644
index 0000000..7cc1748
--- /dev/null
+++ b/Tests/ParameterParserTests.vb
@@ -0,0 +1,101 @@
+' -----------------------------------------------------------------------------
+' Copyright (c) svtica. All rights reserved.
+' File: ParameterParserTests.vb
+' Author: LiteTask contributors
+' Date: 2026-04-25
+' Purpose: Unit tests for LiteTask.ParameterParser covering the original
+' unquoted form and the new cmd-style quoting.
+' -----------------------------------------------------------------------------
+Imports Microsoft.VisualStudio.TestTools.UnitTesting
+Imports LiteTask.LiteTask
+
+
+Public Class ParameterParserTests
+
+
+ Public Sub Parse_SinglePair_ReturnsKeyAndValue()
+ Dim result = ParameterParser.Parse("key=value")
+ Assert.AreEqual(1, result.Count)
+ Assert.AreEqual("value", result("key"))
+ End Sub
+
+
+ Public Sub Parse_QuotedValueWithSpaces_KeepsValueIntact()
+ Dim result = ParameterParser.Parse("key=""value with spaces""")
+ Assert.AreEqual(1, result.Count)
+ Assert.AreEqual("value with spaces", result("key"))
+ End Sub
+
+
+ Public Sub Parse_MultiplePairs_AllParsed()
+ Dim result = ParameterParser.Parse("a=1 b=2 c=3")
+ Assert.AreEqual(3, result.Count)
+ Assert.AreEqual("1", result("a"))
+ Assert.AreEqual("2", result("b"))
+ Assert.AreEqual("3", result("c"))
+ End Sub
+
+
+ Public Sub Parse_MixedQuotedAndUnquoted_BothHandled()
+ Dim result = ParameterParser.Parse("a=1 b=""hello world"" c=3")
+ Assert.AreEqual(3, result.Count)
+ Assert.AreEqual("1", result("a"))
+ Assert.AreEqual("hello world", result("b"))
+ Assert.AreEqual("3", result("c"))
+ End Sub
+
+
+ Public Sub Parse_EmptyQuotedValue_YieldsEmptyString()
+ Dim result = ParameterParser.Parse("a=1 b="""" c=3")
+ Assert.AreEqual(3, result.Count)
+ Assert.AreEqual("", result("b"))
+ End Sub
+
+
+ Public Sub Parse_EmptyUnquotedValue_YieldsEmptyString()
+ Dim result = ParameterParser.Parse("a= b=2")
+ Assert.AreEqual(2, result.Count)
+ Assert.AreEqual("", result("a"))
+ Assert.AreEqual("2", result("b"))
+ End Sub
+
+
+ Public Sub Parse_NullOrEmpty_ReturnsEmptyDictionary()
+ Assert.AreEqual(0, ParameterParser.Parse(Nothing).Count)
+ Assert.AreEqual(0, ParameterParser.Parse("").Count)
+ Assert.AreEqual(0, ParameterParser.Parse(" ").Count)
+ End Sub
+
+
+ Public Sub Parse_TokenWithoutEquals_IsSkipped()
+ Dim result = ParameterParser.Parse("a=1 garbage b=2")
+ Assert.AreEqual(2, result.Count)
+ Assert.AreEqual("1", result("a"))
+ Assert.AreEqual("2", result("b"))
+ Assert.IsFalse(result.ContainsKey("garbage"))
+ End Sub
+
+
+ Public Sub Parse_QuotedValueFollowedByMore_ContinuesParsing()
+ Dim result = ParameterParser.Parse("path=""C:\Program Files\App"" mode=fast")
+ Assert.AreEqual(2, result.Count)
+ Assert.AreEqual("C:\Program Files\App", result("path"))
+ Assert.AreEqual("fast", result("mode"))
+ End Sub
+
+
+ Public Sub Parse_DuplicateKey_LastWriteWins()
+ Dim result = ParameterParser.Parse("a=1 a=2 a=3")
+ Assert.AreEqual(1, result.Count)
+ Assert.AreEqual("3", result("a"))
+ End Sub
+
+
+ Public Sub Parse_LeadingTrailingWhitespace_Tolerated()
+ Dim result = ParameterParser.Parse(" a=1 b=2 ")
+ Assert.AreEqual(2, result.Count)
+ Assert.AreEqual("1", result("a"))
+ Assert.AreEqual("2", result("b"))
+ End Sub
+
+End Class