Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
92 changes: 92 additions & 0 deletions Class/ParameterParser.vb
Original file line number Diff line number Diff line change
@@ -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

''' <summary>
''' 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.
''' </summary>
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
74 changes: 73 additions & 1 deletion Class/SettingsValidator.vb
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Expand Down
17 changes: 2 additions & 15 deletions Class/TaskRunner.vb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 25 additions & 7 deletions Forms/ActionDialog.vb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions Forms/TaskForm.vb
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,11 @@ Namespace LiteTask

UpdateTaskFromForm()

If Not ValidateTaskActions() Then
DialogResult = DialogResult.None
Return
End If

If _isEditMode Then
_customScheduler.UpdateTask(_task)
Else
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions LiteTask.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions LiteTask.vbproj
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,17 @@
<Compile Remove="res\txt\**" />
<Compile Remove="System\**" />
<Compile Remove="tools\**" />
<Compile Remove="Tests\**" />
<EmbeddedResource Remove="res\lang\**" />
<EmbeddedResource Remove="res\txt\**" />
<EmbeddedResource Remove="System\**" />
<EmbeddedResource Remove="tools\**" />
<EmbeddedResource Remove="Tests\**" />
<None Remove="res\lang\**" />
<None Remove="res\txt\**" />
<None Remove="System\**" />
<None Remove="tools\**" />
<None Remove="Tests\**" />
</ItemGroup>
<ItemGroup>
<None Remove="C:\Users\dricard\.nuget\packages\microsoft.powershell.sdk\7.4.6\contentFiles\any\any\runtimes\unix\lib\net8.0\Modules\Microsoft.PowerShell.Host\Microsoft.PowerShell.Host.psd1" />
Expand Down
11 changes: 11 additions & 0 deletions LiteTaskData/lang/fr.xml
Original file line number Diff line number Diff line change
Expand Up @@ -393,4 +393,15 @@
<translation key="Options.Tooltip.DailyRestart">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.</translation>
<translation key="Options.Tooltip.DailyRestartTime">Heure du redémarrage quotidien du service (par défaut : 03h00)</translation>
<translation key="Options.Tooltip.DailyRestartNotification">Envoyer une notification par courriel lors du redémarrage quotidien</translation>

<!-- Task / Action Validation -->
<translation key="Validation.Error">Erreur de validation</translation>
<translation key="Validation.Action.NameRequired">Le nom de l'action est requis</translation>
<translation key="Validation.Action.TargetRequired">La cible de l'action est requise</translation>
<translation key="Validation.Action.TargetMissing">Le fichier cible n'existe pas : {0}</translation>
<translation key="Validation.Action.TimeoutMin">Le délai d'expiration doit être d'au moins 1 minute</translation>
<translation key="Validation.Action.RetryCountNegative">Le nombre de tentatives ne peut pas être négatif</translation>
<translation key="Validation.Action.RetryDelayNegative">Le délai entre tentatives ne peut pas être négatif</translation>
<translation key="Validation.Action.DependsOnSelf">L'action '{0}' ne peut pas dépendre d'elle-même</translation>
<translation key="Validation.Action.DependsOnUnknown">DependsOn référence une action inconnue : {0}</translation>
</translations>
22 changes: 22 additions & 0 deletions Tests/LiteTask.Tests.vbproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows8.0</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>LiteTask.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<Platforms>AnyCPU;x64</Platforms>
<NoWarn>$(NoWarn);BC42021</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.6.1" />
<PackageReference Include="MSTest.TestFramework" Version="3.6.1" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\LiteTask.vbproj" />
</ItemGroup>
</Project>
Loading
Loading