diff --git a/src/ColumnizerLib/ILogFileInfo.cs b/src/ColumnizerLib/ILogFileInfo.cs
index 6a10b530c..ed5a36309 100644
--- a/src/ColumnizerLib/ILogFileInfo.cs
+++ b/src/ColumnizerLib/ILogFileInfo.cs
@@ -51,6 +51,12 @@ public interface ILogFileInfo
///
bool FileExists { get; }
+ ///
+ /// Gets the time when the file was last written, in UTC, or when the file system plugin
+ /// cannot provide it.
+ ///
+ DateTime? LastWriteTimeUtc => null;
+
///
/// The interval (in ms) LogExpert should check for file changes. The property is checked by LogExpert repeatedly in the loop which
/// checks for file changes. So you can adjust the poll interval as needed. E.g. you can lower the interval when many changes occur and
diff --git a/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs b/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs
index 1c4fe52c9..153618015 100644
--- a/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs
+++ b/src/LogExpert.Core/Classes/Log/RolloverFilenameHandler.cs
@@ -21,6 +21,7 @@ public class RolloverFilenameHandler
private readonly RolloverFilenameBuilder _filenameBuilder;
private readonly ILogFileInfo _logFileInfo;
private readonly MultiFileOptions _options;
+ private readonly TimeProvider _timeProvider;
#endregion
@@ -31,10 +32,12 @@ public class RolloverFilenameHandler
///
/// The complete path of the logfile
/// Multifile option (e.g. format pattern)
- public RolloverFilenameHandler(ILogFileInfo logFileInfo, MultiFileOptions options)
+ /// Time source used to calculate the file-age cutoff.
+ public RolloverFilenameHandler(ILogFileInfo logFileInfo, MultiFileOptions options, TimeProvider? timeProvider = null)
{
_options = options;
_logFileInfo = logFileInfo;
+ _timeProvider = timeProvider ?? TimeProvider.System;
_filenameBuilder = new RolloverFilenameBuilder(_options.FormatPattern);
_filenameBuilder.SetFileName(logFileInfo.FileName);
}
@@ -69,12 +72,18 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry)
_filenameBuilder.Index += 1;
fileName = _filenameBuilder.BuildFileName();
filePath = _logFileInfo.DirectoryName + _logFileInfo.DirectorySeparatorChar + fileName;//TODO: Change to Directory.Combine
- if (FileExists(filePath, pluginRegistry))
+ var result = GetCandidateResult(filePath, pluginRegistry);
+ if (result == CandidateResult.Include)
{
_ = fileList.AddFirst(filePath);
found = true;
continue;
}
+
+ if (result == CandidateResult.TooOld)
+ {
+ return fileList;
+ }
}
// if file with index isn't found or no index is in format pattern, decrement the current date
@@ -87,13 +96,19 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry)
_filenameBuilder.DecrementDate();
fileName = _filenameBuilder.BuildFileName();
filePath = _logFileInfo.DirectoryName + _logFileInfo.DirectorySeparatorChar + fileName;//TODO: Change to Directory.Combine
- if (FileExists(filePath, pluginRegistry))
+ var result = GetCandidateResult(filePath, pluginRegistry);
+ if (result == CandidateResult.Include)
{
_ = fileList.AddFirst(filePath);
found = true;
break;
}
+ if (result == CandidateResult.TooOld)
+ {
+ return fileList;
+ }
+
tryCounter++;
}
}
@@ -106,17 +121,36 @@ public LinkedList GetNameList(IPluginRegistry pluginRegistry)
#region Private Methods
- private bool FileExists(string filePath, IPluginRegistry pluginRegistry)
+ private CandidateResult GetCandidateResult(string filePath, IPluginRegistry pluginRegistry)
{
var fs = pluginRegistry.FindFileSystemForUri(filePath);
if(fs == null)
{
- return false;
+ return CandidateResult.Missing;
}
var info = fs.GetLogfileInfo(filePath);
- return info is not null && info.FileExists;
+ if (info is null || !info.FileExists)
+ {
+ return CandidateResult.Missing;
+ }
+
+ var maxFileAgeDays = MultiFileOptions.ClampMaxFileAgeDays(_options.MaxFileAgeDays);
+ if (maxFileAgeDays == 0 || info.LastWriteTimeUtc is not { } lastWriteTimeUtc)
+ {
+ return CandidateResult.Include;
+ }
+
+ var cutoffUtc = _timeProvider.GetUtcNow().UtcDateTime - TimeSpan.FromDays(maxFileAgeDays);
+ return lastWriteTimeUtc >= cutoffUtc ? CandidateResult.Include : CandidateResult.TooOld;
+ }
+
+ private enum CandidateResult
+ {
+ Missing,
+ Include,
+ TooOld
}
#endregion
diff --git a/src/LogExpert.Core/Classes/Persister/PersistenceData.cs b/src/LogExpert.Core/Classes/Persister/PersistenceData.cs
index 37e80e377..fb72943c0 100644
--- a/src/LogExpert.Core/Classes/Persister/PersistenceData.cs
+++ b/src/LogExpert.Core/Classes/Persister/PersistenceData.cs
@@ -71,6 +71,8 @@ public class PersistenceData
public int MultiFileMaxDays { get; set; }
+ public int MultiFileMaxFileAgeDays { get; set; }
+
public List MultiFileNames { get; set; } = [];
public string MultiFilePattern { get; set; }
diff --git a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs
index 490aa8c83..5482075e2 100644
--- a/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs
+++ b/src/LogExpert.Core/Classes/Persister/SessionFileComposer.cs
@@ -36,6 +36,7 @@ public static PersistenceData Compose (SessionSnapshot snapshot)
FilterSaveListVisible = snapshot.FilterSaveListVisible,
MultiFile = snapshot.MultiFile,
MultiFileMaxDays = snapshot.MultiFileMaxDays,
+ MultiFileMaxFileAgeDays = snapshot.MultiFileMaxFileAgeDays,
MultiFilePattern = snapshot.MultiFilePattern,
TabName = snapshot.TabName,
HighlightGroupName = snapshot.HighlightGroupName,
@@ -83,6 +84,7 @@ public static SessionSnapshot Decompose (PersistenceData persistenceData)
FilterSaveListVisible = persistenceData.FilterSaveListVisible,
MultiFile = persistenceData.MultiFile,
MultiFileMaxDays = persistenceData.MultiFileMaxDays,
+ MultiFileMaxFileAgeDays = persistenceData.MultiFileMaxFileAgeDays,
MultiFilePattern = persistenceData.MultiFilePattern,
TabName = persistenceData.TabName,
HighlightGroupName = persistenceData.HighlightGroupName,
diff --git a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs
index bec804861..462309012 100644
--- a/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs
+++ b/src/LogExpert.Core/Classes/Persister/SessionSnapshot.cs
@@ -43,6 +43,8 @@ public class SessionSnapshot
public int MultiFileMaxDays { get; set; }
+ public int MultiFileMaxFileAgeDays { get; set; }
+
public string MultiFilePattern { get; set; }
public string TabName { get; set; }
diff --git a/src/LogExpert.Core/Entities/MultifileOptions.cs b/src/LogExpert.Core/Entities/MultifileOptions.cs
index b9ab6d782..4d5d6f292 100644
--- a/src/LogExpert.Core/Entities/MultifileOptions.cs
+++ b/src/LogExpert.Core/Entities/MultifileOptions.cs
@@ -5,8 +5,24 @@ public class MultiFileOptions
{
#region Properties
+ ///
+ /// The largest file age exposed by the UI and accepted by the rollover reader, in days.
+ ///
+ public const int MAX_FILE_AGE_DAYS = 3650;
+
public int MaxDayTry { get; set; } = 3;
+ ///
+ /// Gets or sets the maximum age, in days, of rollover files loaded into a MultiFile. Zero disables the limit.
+ /// Values greater than are treated as that maximum.
+ ///
+ public int MaxFileAgeDays { get; set; }
+
+ public static int ClampMaxFileAgeDays (int value)
+ {
+ return Math.Clamp(value, 0, MAX_FILE_AGE_DAYS);
+ }
+
public string FormatPattern { get; set; } = "*$J(.)";
#endregion
diff --git a/src/LogExpert.Persister.Tests/PersisterTests.cs b/src/LogExpert.Persister.Tests/PersisterTests.cs
index 52e0dcb78..b07be02cd 100644
--- a/src/LogExpert.Persister.Tests/PersisterTests.cs
+++ b/src/LogExpert.Persister.Tests/PersisterTests.cs
@@ -357,6 +357,7 @@ public void RoundTrip_WithApplicationStartupDir_PreservesAllData ()
MultiFile = true,
MultiFilePattern = "test*.log",
MultiFileMaxDays = 7,
+ MultiFileMaxFileAgeDays = 5,
LineCount = 1000,
CellSelectMode = true
};
@@ -377,6 +378,8 @@ public void RoundTrip_WithApplicationStartupDir_PreservesAllData ()
Assert.That(loadedData.MultiFile, Is.EqualTo(originalData.MultiFile), "MultiFile should match");
Assert.That(loadedData.MultiFilePattern, Is.EqualTo(originalData.MultiFilePattern), "MultiFilePattern should match");
Assert.That(loadedData.MultiFileMaxDays, Is.EqualTo(originalData.MultiFileMaxDays), "MultiFileMaxDays should match");
+ Assert.That(loadedData.MultiFileMaxFileAgeDays, Is.EqualTo(originalData.MultiFileMaxFileAgeDays),
+ "MultiFileMaxFileAgeDays should match");
Assert.That(loadedData.LineCount, Is.EqualTo(originalData.LineCount), "LineCount should match");
Assert.That(loadedData.CellSelectMode, Is.EqualTo(originalData.CellSelectMode), "CellSelectMode should match");
}
diff --git a/src/LogExpert.Persister.Tests/PersisterXmlMalformedTests.cs b/src/LogExpert.Persister.Tests/PersisterXmlMalformedTests.cs
index ee6aaba95..b4546e5f7 100644
--- a/src/LogExpert.Persister.Tests/PersisterXmlMalformedTests.cs
+++ b/src/LogExpert.Persister.Tests/PersisterXmlMalformedTests.cs
@@ -44,6 +44,7 @@ public void Load_FileElementWithoutOptions_ReturnsDefaults ()
Assert.That(data.MultiFile, Is.False);
Assert.That(data.MultiFilePattern, Is.Null);
Assert.That(data.MultiFileMaxDays, Is.Zero);
+ Assert.That(data.MultiFileMaxFileAgeDays, Is.Zero);
Assert.That(data.FilterVisible, Is.False);
// A missing means "nothing was persisted", so every option keeps its
diff --git a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs
index b1c97af82..f820d7165 100644
--- a/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs
+++ b/src/LogExpert.Persister.Tests/SessionFileComposerTests.cs
@@ -172,6 +172,7 @@ private static SessionSnapshot CreateFullSnapshot (int filterTabDepth = 2)
FilterSaveListVisible = true,
MultiFile = true,
MultiFileMaxDays = 7,
+ MultiFileMaxFileAgeDays = 5,
MultiFilePattern = "app*.log",
TabName = "My Tab",
HighlightGroupName = "Errors",
@@ -247,6 +248,7 @@ private static PersistenceData CreateCarriedPersistenceData (int filterTabDepth)
FilterSaveListVisible = true,
MultiFile = true,
MultiFileMaxDays = 7,
+ MultiFileMaxFileAgeDays = 5,
MultiFilePattern = "app*.log",
TabName = "My Tab",
HighlightGroupName = "Errors",
@@ -422,6 +424,28 @@ public void DiskTrip_ComposePersistLoadDecompose_ReturnsEqualSnapshot ()
Assert.That(SerializeForComparison(roundTripped), Is.EqualTo(SerializeForComparison(snapshot)));
}
+ [Test]
+ public void Decompose_LegacyJsonWithoutMaxFileAgeDays_DefaultsToOff ()
+ {
+ var sessionFileName = Path.Join(_testDirectory, "legacy-json.lxp");
+ File.WriteAllText(sessionFileName, """
+ {
+ "MultiFile": true,
+ "MultiFileMaxDays": 7,
+ "MultiFilePattern": "*$J(.)"
+ }
+ """);
+
+ var loaded = Core.Classes.Persister.Persister.Load(sessionFileName);
+ var snapshot = SessionFileComposer.Decompose(loaded);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(loaded.MultiFileMaxFileAgeDays, Is.Zero);
+ Assert.That(snapshot.MultiFileMaxFileAgeDays, Is.Zero);
+ });
+ }
+
#endregion
#region Window filter (issue #666)
@@ -490,6 +514,8 @@ public void Decompose_LegacyXmlSessionFileOnDisk_TakesTheFirstFilterAsTheWindowF
Assert.That(snapshot.FilterParams.SearchText, Is.EqualTo("the first global filter"));
Assert.That(snapshot.FilterParams.SpreadBefore, Is.EqualTo(1),
"the restored entry must be the first one, whole — the second carries SpreadBefore 2");
+ Assert.That(snapshot.MultiFileMaxFileAgeDays, Is.Zero,
+ "legacy XML Session Files without the new field must keep age filtering disabled");
Assert.That(snapshot.FilterParams.Color, Is.EqualTo(Color.Black),
"Color is the field the legacy payload writes as \"Black\" — losing it means the entry was not really read");
}
diff --git a/src/LogExpert.Resources/Resources.Designer.cs b/src/LogExpert.Resources/Resources.Designer.cs
index 6bf4905df..625847afc 100644
--- a/src/LogExpert.Resources/Resources.Designer.cs
+++ b/src/LogExpert.Resources/Resources.Designer.cs
@@ -19,7 +19,7 @@ namespace LogExpert {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
- [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
@@ -416,7 +416,7 @@ public static string Columnizer_SnapshotUnavailable {
return ResourceManager.GetString("Columnizer_SnapshotUnavailable", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Timestamp selector.
///
@@ -1081,6 +1081,123 @@ public static string HighlightDialog_UI_Button_SelectPlugin {
}
}
+ ///
+ /// Looks up a localized string similar to Create a new highlight item (opens the editor dialog).
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnAdd {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnAdd", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Copy the selected highlight into a new one (opens the editor dialog).
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnCopy {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnCopy", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Copy the current highlight group into a new one.
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnCopyGroup {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnCopyGroup", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Delete the current highlight.
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnDelete {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnDelete", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Delete the current highlight group.
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnDeleteGroup {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnDeleteGroup", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Edit the selected highlight in the editor dialog.
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnEdit {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnEdit", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Export highlight groups.
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnExportGroup {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnExportGroup", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Import highlight groups.
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnImportGroup {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnImportGroup", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Move the current highlight one position down.
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnMoveDown {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnMoveDown", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Move the current highlight group one position down.
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnMoveGroupDown {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnMoveGroupDown", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Move the current highlight group one position up.
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnMoveGroupUp {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnMoveGroupUp", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Move the current highlight one position up.
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnMoveUp {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnMoveUp", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Create a new empty highlight group.
+ ///
+ public static string HighlightDialog_UI_Button_ToolTip_btnNewGroup {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnNewGroup", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Change selection highlight color....
///
@@ -1189,6 +1306,15 @@ public static string HighlightDialog_UI_CheckBox_WordMatch {
}
}
+ ///
+ /// Looks up a localized string similar to Choose a group to create different highlight settings. Type in a name to change the name of a group..
+ ///
+ public static string HighlightDialog_UI_ComboBox_ToolTip_comboBoxGroups {
+ get {
+ return ResourceManager.GetString("HighlightDialog_UI_ComboBox_ToolTip_comboBoxGroups", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to [Default].
///
@@ -4208,7 +4334,7 @@ public static string MarkerBar_Bookmarks {
return ResourceManager.GetString("MarkerBar_Bookmarks", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Clear search.
///
@@ -4217,7 +4343,7 @@ public static string MarkerBar_ClearSearch {
return ResourceManager.GetString("MarkerBar_ClearSearch", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Discovering markers….
///
@@ -4226,7 +4352,7 @@ public static string MarkerBar_Discovering {
return ResourceManager.GetString("MarkerBar_Discovering", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Filter hits.
///
@@ -4235,7 +4361,7 @@ public static string MarkerBar_FilterHits {
return ResourceManager.GetString("MarkerBar_FilterHits", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Highlights.
///
@@ -4244,7 +4370,7 @@ public static string MarkerBar_Highlights {
return ResourceManager.GetString("MarkerBar_Highlights", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Could not read log line {0} while discovering markers..
///
@@ -4253,7 +4379,7 @@ public static string MarkerBar_LineUnavailable {
return ResourceManager.GetString("MarkerBar_LineUnavailable", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Marker discovery failed: {0}.
///
@@ -4262,7 +4388,7 @@ public static string MarkerBar_ScanFailed {
return ResourceManager.GetString("MarkerBar_ScanFailed", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Search hits.
///
@@ -4271,7 +4397,7 @@ public static string MarkerBar_SearchHits {
return ResourceManager.GetString("MarkerBar_SearchHits", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Marker bar.
///
@@ -4280,7 +4406,7 @@ public static string MarkerBar_Title {
return ResourceManager.GetString("MarkerBar_Title", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to {0}: lines {1}–{2}, {3} matching lines.
///
@@ -4289,7 +4415,7 @@ public static string MarkerBar_ToolTip {
return ResourceManager.GetString("MarkerBar_ToolTip", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Close existing tabs.
///
@@ -4435,11 +4561,29 @@ public static string MultiFileMaskDialog_UI_Label_FileNamePattern {
}
///
- /// Looks up a localized string similar to Max days:.
+ /// Looks up a localized string similar to Max day gap:.
///
- public static string MultiFileMaskDialog_UI_Label_MaxDays {
+ public static string MultiFileMaskDialog_UI_Label_MaxDayGap {
get {
- return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxDays", resourceCulture);
+ return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxDayGap", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Max file age (days):.
+ ///
+ public static string MultiFileMaskDialog_UI_Label_MaxFileAgeDays {
+ get {
+ return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxFileAgeDays", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to 0 = Off.
+ ///
+ public static string MultiFileMaskDialog_UI_Label_MaxFileAgeDaysHint {
+ get {
+ return ResourceManager.GetString("MultiFileMaskDialog_UI_Label_MaxFileAgeDaysHint", resourceCulture);
}
}
@@ -4456,16 +4600,24 @@ public static string MultiFileMaskDialog_UI_Label_SettingsFor {
/// Looks up a localized string similar to Pattern syntax:
///
///* = any characters (wildcard)
- ///$D(<date>) = Date pattern
+ ///$D(<date>) = Date pattern
///$I = File index number
///$J = File index number, hidden when zero
- ///$J(<prefix>) = Like $J, but adding <prefix> when non-zero
+ ///$J(<prefix>) = Like $J, but adding <prefix> when non-zero
+ ///
+ ///Examples:
+ ///*$J(.) → app.log, app.log.1, app.log.2
+ ///*$J(.).log → app.log, app.1.log, app.2.log
///
- ///<date>:
+ ///<date>:
///DD = day
///MM = month
///YY[YY] = year
- ///all other chars will be used as given.
+ ///all other chars will be used as given
+ ///
+ ///MultiFile limits:
+ ///Max day gap = For $D patterns, consecutive days without a log file to skip before giving up
+ ///Max file age [rest of string was truncated]";.
///
public static string MultiFileMaskDialog_UI_Label_SyntaxHelp {
get {
@@ -4564,7 +4716,7 @@ public static string ParamRequesterDialog_UI_Label_ValueForParameter {
}
///
- /// Looks up a localized string similar to &Value for "{0}":.
+ /// Looks up a localized string similar to &Value for "{0}":.
///
public static string ParamRequesterDialog_UI_Label_ValueForParameterNamed {
get {
@@ -5642,7 +5794,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowBookmarkMarkers {
return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowBookmarkMarkers", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Show Error Message?.
///
@@ -5660,7 +5812,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowFilterMarkers {
return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowFilterMarkers", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Show highlight markers.
///
@@ -5669,7 +5821,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowHighlightMarkers {
return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowHighlightMarkers", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Show marker bar.
///
@@ -5678,7 +5830,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowMarkerBar {
return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowMarkerBar", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Show search markers.
///
@@ -5687,7 +5839,7 @@ public static string SettingsDialog_UI_CheckBox_checkBoxShowSearchMarkers {
return ResourceManager.GetString("SettingsDialog_UI_CheckBox_checkBoxShowSearchMarkers", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Allow only 1 Instance.
///
@@ -6193,29 +6345,47 @@ public static string SettingsDialog_UI_Label_labelLinesPerBlock {
}
///
- /// Looks up a localized string similar to Max days:.
+ /// Looks up a localized string similar to Max day gap:.
///
- public static string SettingsDialog_UI_Label_labelMaxDays {
+ public static string SettingsDialog_UI_Label_labelMaxDayGap {
get {
- return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxDays", resourceCulture);
+ return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxDayGap", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Maximum filter entries.
+ /// Looks up a localized string similar to Maximum Display Length (restart required).
///
- public static string SettingsDialog_UI_Label_labelMaximumFilterEntries {
+ public static string SettingsDialog_UI_Label_labelMaxDisplayLength {
get {
- return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaximumFilterEntries", resourceCulture);
+ return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxDisplayLength", resourceCulture);
}
}
///
- /// Looks up a localized string similar to Maximum Display Length (restart required).
+ /// Looks up a localized string similar to Max file age (days):.
///
- public static string SettingsDialog_UI_Label_labelMaxDisplayLength {
+ public static string SettingsDialog_UI_Label_labelMaxFileAgeDays {
get {
- return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxDisplayLength", resourceCulture);
+ return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxFileAgeDays", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to 0 = Off.
+ ///
+ public static string SettingsDialog_UI_Label_labelMaxFileAgeDaysHint {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaxFileAgeDaysHint", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Maximum filter entries.
+ ///
+ public static string SettingsDialog_UI_Label_labelMaximumFilterEntries {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_Label_labelMaximumFilterEntries", resourceCulture);
}
}
@@ -6328,6 +6498,24 @@ public static string SettingsDialog_UI_Label_labelWorkingDir {
}
}
+ ///
+ /// Looks up a localized string similar to For $D patterns: how many consecutive days without a log file to skip before giving up..
+ ///
+ public static string SettingsDialog_UI_NumericUpDown_ToolTip_upDownMultifileDays {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_NumericUpDown_ToolTip_upDownMultifileDays", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Maximum age of rollover files to load, in days. Set to 0 to disable the age limit..
+ ///
+ public static string SettingsDialog_UI_NumericUpDown_ToolTip_upDownMultifileMaxFileAgeDays {
+ get {
+ return ResourceManager.GetString("SettingsDialog_UI_NumericUpDown_ToolTip_upDownMultifileMaxFileAgeDays", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Failed to activate portable mode: {0}.
///
@@ -6615,7 +6803,7 @@ public static string SettingsDialog_UI_TabPage_tabPageMarkerBar {
return ResourceManager.GetString("SettingsDialog_UI_TabPage_tabPageMarkerBar", resourceCulture);
}
}
-
+
///
/// Looks up a localized string similar to Memory/CPU.
///
@@ -6810,131 +6998,5 @@ public static string ToolArgsDialog_UI_Title {
return ResourceManager.GetString("ToolArgsDialog_UI_Title", resourceCulture);
}
}
-
- ///
- /// Looks up a localized string similar to Create a new highlight item (opens the editor dialog).
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnAdd {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnAdd", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Copy the selected highlight into a new one (opens the editor dialog).
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnCopy {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnCopy", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Edit the selected highlight in the editor dialog.
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnEdit {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnEdit", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Delete the current highlight.
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnDelete {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnDelete", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Move the current highlight one position up.
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnMoveUp {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnMoveUp", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Move the current highlight one position down.
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnMoveDown {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnMoveDown", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Export highlight groups.
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnExportGroup {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnExportGroup", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Import highlight groups.
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnImportGroup {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnImportGroup", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Move the current highlight group one position down.
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnMoveGroupDown {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnMoveGroupDown", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Move the current highlight group one position up.
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnMoveGroupUp {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnMoveGroupUp", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Copy the current highlight group into a new one.
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnCopyGroup {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnCopyGroup", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Delete the current highlight group.
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnDeleteGroup {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnDeleteGroup", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Create a new empty highlight group.
- ///
- public static string HighlightDialog_UI_Button_ToolTip_btnNewGroup {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_Button_ToolTip_btnNewGroup", resourceCulture);
- }
- }
-
- ///
- /// Looks up a localized string similar to Choose a group to create different highlight settings. Type in a name to change the name of a group..
- ///
- public static string HighlightDialog_UI_ComboBox_ToolTip_comboBoxGroups {
- get {
- return ResourceManager.GetString("HighlightDialog_UI_ComboBox_ToolTip_comboBoxGroups", resourceCulture);
- }
- }
}
}
diff --git a/src/LogExpert.Resources/Resources.de.resx b/src/LogExpert.Resources/Resources.de.resx
index 67ef82bf3..f204a5fc5 100644
--- a/src/LogExpert.Resources/Resources.de.resx
+++ b/src/LogExpert.Resources/Resources.de.resx
@@ -736,8 +736,20 @@ Damit die anderen Fenster beim Auswählen einer Zeile (Mausklick oder Pfeiltaste
Pattern:
-
- Maximale Tage:
+
+ Max. Tageslücke:
+
+
+ Max. Dateialter (Tage):
+
+
+ 0 = Aus
+
+
+ Bei $D-Mustern: Anzahl aufeinanderfolgender Tage ohne Protokolldatei, die vor dem Abbruch übersprungen werden.
+
+
+ Maximales Alter der zu ladenden Rollover-Dateien in Tagen. 0 deaktiviert die Altersgrenze.
Argumente:
@@ -1691,8 +1703,14 @@ Ein ausgewähltes Tool erscheint in der Iconbar. Alle anderen verfügbaren Tools
Dateinamemuster:
-
- Max Tage:
+
+ Max. Tageslücke:
+
+
+ Max. Dateialter (Tage):
+
+
+ 0 = Aus
Muster syntax:
@@ -1711,7 +1729,11 @@ Beispiele:
DD = Tag
MM = Monat
YY[YY] = Jahr
-Alle anderen Zeichen werden benutzt wie sie angegeben sind
+Alle anderen Zeichen werden benutzt wie sie angegeben sind
+
+MultiFile-Grenzen:
+Max. Tageslücke = Bei $D-Mustern aufeinanderfolgende Tage ohne Protokolldatei, die vor dem Abbruch übersprungen werden
+Max. Dateialter (Tage) = Älteste zu ladende Rollover-Dateien in Tagen; 0 deaktiviert die Altersgrenze
Spalten
diff --git a/src/LogExpert.Resources/Resources.resx b/src/LogExpert.Resources/Resources.resx
index bd9b3a535..65907ae72 100644
--- a/src/LogExpert.Resources/Resources.resx
+++ b/src/LogExpert.Resources/Resources.resx
@@ -722,8 +722,20 @@ Checked tools will appear in the icon bar. All other tools are available in the
Arguments:
-
- Max days:
+
+ Max day gap:
+
+
+ Max file age (days):
+
+
+ 0 = Off
+
+
+ For $D patterns: how many consecutive days without a log file to skip before giving up.
+
+
+ Maximum age of rollover files to load, in days. Set to 0 to disable the age limit.
Pattern:
@@ -1742,8 +1754,14 @@ Checked tools will appear in the icon bar. All other tools are available in the
File name pattern:
-
- Max days:
+
+ Max day gap:
+
+
+ Max file age (days):
+
+
+ 0 = Off
Pattern syntax:
@@ -1762,7 +1780,11 @@ Examples:
DD = day
MM = month
YY[YY] = year
-all other chars will be used as given
+all other chars will be used as given
+
+MultiFile limits:
+Max day gap = For $D patterns, consecutive days without a log file to skip before giving up
+Max file age (days) = Oldest rollover files to load; 0 disables the age limit
Columns
diff --git a/src/LogExpert.Resources/Resources.zh-CN.resx b/src/LogExpert.Resources/Resources.zh-CN.resx
index 9b875814b..b52b2490b 100644
--- a/src/LogExpert.Resources/Resources.zh-CN.resx
+++ b/src/LogExpert.Resources/Resources.zh-CN.resx
@@ -622,8 +622,20 @@
参数:
-
- 最大天数:
+
+ 最大日期间隔:
+
+
+ 最大文件年龄(天):
+
+
+ 0 = 关闭
+
+
+ 对于 $D 模式:放弃搜索前可跳过的连续无日志文件天数。
+
+
+ 要加载的滚动日志文件的最大天数。设置为 0 可关闭文件期限限制。
模式:
@@ -1560,8 +1572,14 @@
文件名模式:
-
- 最大天数:
+
+ 最大日期间隔:
+
+
+ 最大文件年龄(天):
+
+
+ 0 = 关闭
模式语法:
@@ -1580,7 +1598,11 @@ $J(<前缀>) = 类似 $J,但在非零时添加 <前缀>
DD = 日
MM = 月
YY[YY] = 年
-其它字符将按原样使用
+其它字符将按原样使用
+
+多文件限制:
+最大日期间隔 = 对于 $D 模式,放弃搜索前可跳过的连续无日志文件天数
+最大文件年龄(天) = 要加载的最旧滚动日志文件天数;0 表示关闭期限限制
列
diff --git a/src/LogExpert.Tests/ConfigManagerTests/PreferencesMultiFileOptionsTests.cs b/src/LogExpert.Tests/ConfigManagerTests/PreferencesMultiFileOptionsTests.cs
new file mode 100644
index 000000000..b076eb07c
--- /dev/null
+++ b/src/LogExpert.Tests/ConfigManagerTests/PreferencesMultiFileOptionsTests.cs
@@ -0,0 +1,70 @@
+using System.Reflection;
+
+using LogExpert.Configuration;
+using LogExpert.Core.Config;
+using LogExpert.Core.Entities;
+
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests.ConfigManagerTests;
+
+[TestFixture]
+public class PreferencesMultiFileOptionsTests
+{
+ [Test]
+ public void Deserialize_LegacyJsonWithoutMaxFileAgeDays_DefaultsToOff ()
+ {
+ const string legacyJson = """
+ {
+ "MultiFileOptions": {
+ "FormatPattern": "*$J(.)",
+ "MaxDayTry": 7
+ }
+ }
+ """;
+
+ var preferences = JsonConvert.DeserializeObject(legacyJson);
+
+ Assert.That(preferences, Is.Not.Null);
+ Assert.Multiple(() =>
+ {
+ Assert.That(preferences!.MultiFileOptions.MaxDayTry, Is.EqualTo(7));
+ Assert.That(preferences.MultiFileOptions.MaxFileAgeDays, Is.Zero);
+ });
+ }
+
+ [Test]
+ public void RoundTrip_PreservesMaxFileAgeDays ()
+ {
+ var preferences = new Preferences
+ {
+ MultiFileOptions = new MultiFileOptions { MaxFileAgeDays = 14 }
+ };
+
+ var restored = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(preferences));
+
+ Assert.That(restored, Is.Not.Null);
+ Assert.That(restored!.MultiFileOptions.MaxFileAgeDays, Is.EqualTo(14));
+ }
+
+ [Test]
+ public void Serialize_ContainsOnlyPersistedMultiFileOptions ()
+ {
+ var preferences = new Preferences
+ {
+ MultiFileOptions = new MultiFileOptions { MaxFileAgeDays = 14 }
+ };
+
+ var settingsField = typeof(ConfigManager).GetField("_jsonSettings", BindingFlags.NonPublic | BindingFlags.Static);
+ Assert.That(settingsField, Is.Not.Null);
+ var serializerSettings = (JsonSerializerSettings)settingsField!.GetValue(null)!;
+
+ var json = JsonConvert.SerializeObject(preferences.MultiFileOptions, serializerSettings);
+ var propertyNames = JObject.Parse(json).Properties().Select(property => property.Name);
+
+ Assert.That(propertyNames, Is.EquivalentTo(["$id", "FormatPattern", "MaxDayTry", "MaxFileAgeDays"]));
+ }
+}
diff --git a/src/LogExpert.Tests/RolloverFilenameHandlerAgeTests.cs b/src/LogExpert.Tests/RolloverFilenameHandlerAgeTests.cs
new file mode 100644
index 000000000..13b444378
--- /dev/null
+++ b/src/LogExpert.Tests/RolloverFilenameHandlerAgeTests.cs
@@ -0,0 +1,207 @@
+using ColumnizerLib;
+
+using LogExpert.Core.Classes.Log;
+using LogExpert.Core.Entities;
+using LogExpert.Core.Interfaces;
+
+using Moq;
+
+using NUnit.Framework;
+
+namespace LogExpert.Tests;
+
+[TestFixture]
+internal sealed class RolloverFilenameHandlerAgeTests
+{
+ private const string LOG_DIRECTORY = "/logs";
+ private static readonly DateTimeOffset _now = new(2026, 10, 19, 12, 0, 0, TimeSpan.Zero);
+
+ [TestCase("*$J(.)", "app.log.1", "app.log.2")]
+ [TestCase("*$J(.).log", "app.1.log", "app.2.log")]
+ public void GetNameList_MaxFileAge_StopsAtFirstOldIndexedFile (
+ string pattern,
+ string recentFileName,
+ string oldFileName)
+ {
+ var context = CreateContext(
+ "app.log",
+ new MultiFileOptions { FormatPattern = pattern, MaxFileAgeDays = 5 },
+ (recentFileName, _now.AddDays(-2).UtcDateTime),
+ (oldFileName, _now.AddDays(-10).UtcDateTime));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.That(result, Is.EqualTo(new[] { GetPath(recentFileName), GetPath("app.log") }));
+ }
+
+ [Test]
+ public void GetNameList_ZeroMaxFileAge_LoadsAllIndexedFiles ()
+ {
+ var context = CreateContext(
+ "app.log",
+ new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = 0 },
+ ("app.log.1", _now.AddDays(-10).UtcDateTime),
+ ("app.log.2", _now.AddDays(-20).UtcDateTime));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app.log.2"), GetPath("app.log.1"), GetPath("app.log") }));
+ }
+
+ [Test]
+ public void GetNameList_FileAgeAboveMaximum_IsClampedWithoutOverflow ()
+ {
+ var context = CreateContext(
+ "app.log",
+ new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = int.MaxValue },
+ ("app.log.1", _now.AddDays(-10).UtcDateTime));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app.log.1"), GetPath("app.log") }));
+ }
+
+ [Test]
+ public void GetNameList_FirstRolloverIsOld_StopsBeforeNewerHigherIndex ()
+ {
+ var context = CreateContext(
+ "app.log",
+ new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = 5 },
+ ("app.log.1", _now.AddDays(-10).UtcDateTime),
+ ("app.log.2", _now.AddDays(-1).UtcDateTime));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app.log") }));
+ Assert.That(context.RequestedPaths, Does.Not.Contain(GetPath("app.log.2")));
+ });
+ }
+
+ [Test]
+ public void GetNameList_OpenedFileIsOld_StillIncludesOpenedFile ()
+ {
+ var context = CreateContext(
+ "app.log",
+ new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = 5 },
+ activeLastWriteTimeUtc: _now.AddDays(-10).UtcDateTime);
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app.log") }));
+ }
+
+ [Test]
+ public void GetNameList_DatePattern_SkipsConfiguredGapAndStopsAtFirstOldFile ()
+ {
+ var context = CreateContext(
+ "app_2026-10-19.log",
+ new MultiFileOptions
+ {
+ FormatPattern = "*$D(yyyy-MM-dd).log",
+ MaxDayTry = 3,
+ MaxFileAgeDays = 5
+ },
+ ("app_2026-10-17.log", _now.AddDays(-2).UtcDateTime),
+ ("app_2026-10-16.log", _now.AddDays(-10).UtcDateTime));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app_2026-10-17.log"), GetPath("app_2026-10-19.log") }));
+ }
+
+ [Test]
+ public void GetNameList_UnknownLastWriteTime_IncludesFile ()
+ {
+ var context = CreateContext(
+ "app.log",
+ new MultiFileOptions { FormatPattern = "*$J(.)", MaxFileAgeDays = 5 },
+ ("app.log.1", null));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app.log.1"), GetPath("app.log") }));
+ }
+
+ [Test]
+ public void GetNameList_MixedPattern_OldIndexedFileStopsBeforeDateFallback ()
+ {
+ var previousDateFile = "app_2026-10-18.log";
+ var context = CreateContext(
+ "app_2026-10-19.log",
+ new MultiFileOptions
+ {
+ FormatPattern = "*$D(yyyy-MM-dd)$J(.).log",
+ MaxDayTry = 3,
+ MaxFileAgeDays = 5
+ },
+ ("app_2026-10-19.1.log", _now.AddDays(-10).UtcDateTime),
+ (previousDateFile, _now.AddDays(-1).UtcDateTime));
+
+ var result = context.Handler.GetNameList(context.Registry);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result, Is.EqualTo(new[] { GetPath("app_2026-10-19.log") }));
+ Assert.That(context.RequestedPaths, Does.Not.Contain(GetPath(previousDateFile)));
+ });
+ }
+
+ private static TestContext CreateContext (
+ string activeFileName,
+ MultiFileOptions options,
+ params (string FileName, DateTime? LastWriteTimeUtc)[] rolloverFiles)
+ {
+ return CreateContext(activeFileName, options, _now.UtcDateTime, rolloverFiles);
+ }
+
+ private static TestContext CreateContext (
+ string activeFileName,
+ MultiFileOptions options,
+ DateTime? activeLastWriteTimeUtc,
+ params (string FileName, DateTime? LastWriteTimeUtc)[] rolloverFiles)
+ {
+ var activeFile = CreateFileInfo(activeFileName, activeLastWriteTimeUtc);
+ var files = rolloverFiles.ToDictionary(file => GetPath(file.FileName),
+ file => CreateFileInfo(file.FileName, file.LastWriteTimeUtc));
+ List requestedPaths = [];
+
+ var fileSystem = new Mock();
+ _ = fileSystem.Setup(fs => fs.GetLogfileInfo(It.IsAny()))
+ .Returns((string path) =>
+ {
+ requestedPaths.Add(path);
+ return files.GetValueOrDefault(path)?.Object;
+ });
+
+ var registry = new Mock();
+ _ = registry.Setup(r => r.FindFileSystemForUri(It.IsAny())).Returns(fileSystem.Object);
+
+ RolloverFilenameHandler handler = new(activeFile.Object, options, new FixedTimeProvider(_now));
+ return new TestContext(handler, registry.Object, requestedPaths);
+ }
+
+ private static Mock CreateFileInfo (string fileName, DateTime? lastWriteTimeUtc)
+ {
+ var fileInfo = new Mock();
+ _ = fileInfo.SetupGet(file => file.FileName).Returns(fileName);
+ _ = fileInfo.SetupGet(file => file.DirectoryName).Returns(LOG_DIRECTORY);
+ _ = fileInfo.SetupGet(file => file.DirectorySeparatorChar).Returns('/');
+ _ = fileInfo.SetupGet(file => file.FileExists).Returns(true);
+ _ = fileInfo.SetupGet(file => file.LastWriteTimeUtc).Returns(lastWriteTimeUtc);
+ return fileInfo;
+ }
+
+ private static string GetPath (string fileName) => $"{LOG_DIRECTORY}/{fileName}";
+
+ private sealed record TestContext (
+ RolloverFilenameHandler Handler,
+ IPluginRegistry Registry,
+ List RequestedPaths);
+
+ private sealed class FixedTimeProvider (DateTimeOffset utcNow) : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow () => utcNow;
+ }
+}
diff --git a/src/LogExpert.UI.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs b/src/LogExpert.UI.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs
index bae722bb1..3cdae3697 100644
--- a/src/LogExpert.UI.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs
+++ b/src/LogExpert.UI.Tests/Controls/LogWindowCoordinatorIntegrationTests.cs
@@ -1,3 +1,4 @@
+using System.Reflection;
using System.Runtime.Versioning;
using LogExpert.Core.Config;
@@ -115,6 +116,39 @@ public void Preferences_ReadsFromConfigManager_NotCoordinator ()
Assert.That(preferences, Is.SameAs(_settings.Preferences));
}
+ [Test]
+ public void SetDefaultsFromPrefs_SeedsNewWindowWithGlobalMultiFileOptions ()
+ {
+ _settings.Preferences.MultiFileOptions = new MultiFileOptions
+ {
+ FormatPattern = "*$J(.).log",
+ MaxDayTry = 4,
+ MaxFileAgeDays = 30
+ };
+ var setDefaults = typeof(LogWindow).GetMethod("SetDefaultsFromPrefs",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ var optionsField = typeof(LogWindow).GetField("_multiFileOptions",
+ BindingFlags.Instance | BindingFlags.NonPublic);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(setDefaults, Is.Not.Null);
+ Assert.That(optionsField, Is.Not.Null);
+ });
+
+ _ = _logWindow.Handle;
+ _ = setDefaults!.Invoke(_logWindow, null);
+ var windowOptions = (MultiFileOptions)optionsField!.GetValue(_logWindow)!;
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(windowOptions, Is.Not.SameAs(_settings.Preferences.MultiFileOptions));
+ Assert.That(windowOptions.FormatPattern, Is.EqualTo("*$J(.).log"));
+ Assert.That(windowOptions.MaxDayTry, Is.EqualTo(4));
+ Assert.That(windowOptions.MaxFileAgeDays, Is.EqualTo(30));
+ });
+ }
+
[Test]
public void SearchParams_ReadsFromCoordinator ()
{
diff --git a/src/LogExpert.UI.Tests/Dialogs/MultiFileAgeSettingsTests.cs b/src/LogExpert.UI.Tests/Dialogs/MultiFileAgeSettingsTests.cs
new file mode 100644
index 000000000..dfee68483
--- /dev/null
+++ b/src/LogExpert.UI.Tests/Dialogs/MultiFileAgeSettingsTests.cs
@@ -0,0 +1,135 @@
+using System.Reflection;
+
+using LogExpert.Core.Config;
+using LogExpert.Core.Entities;
+using LogExpert.Core.Interfaces;
+using LogExpert.Dialogs;
+using LogExpert.UI.Dialogs;
+
+using Moq;
+
+using NUnit.Framework;
+
+using UIStrings = LogExpert.Resources;
+
+namespace LogExpert.UI.Tests.Dialogs;
+
+[TestFixture]
+[Apartment(ApartmentState.STA)]
+public class MultiFileAgeSettingsTests
+{
+ [Test]
+ public void MultiFileMaskDialog_LoadsAndSavesBothLimits ()
+ {
+ using var dialog = new MultiFileMaskDialog("app.log")
+ {
+ FileNamePattern = "*$J(.)",
+ MaxDayGap = 3,
+ MaxFileAgeDays = 14
+ };
+
+ InvokePrivate(dialog, "OnMultiFileMaskDialogLoad");
+
+ var maxDayGap = Find(dialog, "upDownMaxDayGap");
+ var maxFileAgeDays = Find(dialog, "upDownMaxFileAgeDays");
+ Assert.Multiple(() =>
+ {
+ Assert.That(maxDayGap.Value, Is.EqualTo(3));
+ Assert.That(maxFileAgeDays.Value, Is.EqualTo(14));
+ Assert.That(maxFileAgeDays.Maximum, Is.EqualTo(MultiFileOptions.MAX_FILE_AGE_DAYS));
+ Assert.That(Find