Added file patterns and exclusions to folder data sources (#1057)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Paul KoudelkaandThorsten Sommer authored and GitHub committed 2026-10-17 19:12:43 +02:00
1 parent 249fc778a2
commit 031874cebf
75 files changed
+4808 -589

No files matched your search

@@ -9,17 +9,6 @@
<SelectDirectory Label="@T("Folder containing your documents")" DirectoryDialogTitle="@T("Select the folder containing your documents")" @bind-Directory="@this.inputDirectory" Validation="@this.ValidateInputDirectory" EnableDragDrop="true" Disabled="@this.isProcessingBatch"/>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-1">
<MudTextField T="string" @bind-Text="@this.filePatterns" Validation="@this.ValidateFilePatterns" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("File patterns")" HelperText="@T("Which files should be processed? Separate multiple patterns with a semicolon, e.g., *.pdf;*.docx")" AdornmentIcon="@Icons.Material.Filled.FilterAlt" Adornment="Adornment.Start" Variant="Variant.Outlined" Margin="Margin.Normal" Class="flex-grow-1" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Restore" Disabled="@this.isProcessingBatch" OnClick="@this.RestoreDefaultFilePatterns">
@T("Restore default patterns")
</MudButton>
</MudStack>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Supported audio and video files are transcribed automatically without an additional dialog. Each transcript is stored next to its media file as '<media-file>.transcript.md' and reused when an interrupted run is continued.")
</MudJustifiedText>
<MudTextSwitch Label="@T("Include subfolders?")" Disabled="@this.isProcessingBatch" Value="@this.includeSubdirectories" ValueChanged="@(v => this.includeSubdirectories = v)" LabelOn="@T("Yes, process files in subfolders as well")" LabelOff="@T("No, only process files in the selected folder")"/>
@if (this.includeSubdirectories)
@@ -29,6 +18,32 @@
</MudJustifiedText>
}
<MudTextField T="string" @bind-Text="@this.filePatterns" Validation="@this.ValidateFilePatterns" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("File patterns")" HelperText="@T("Which files should be processed? Separate multiple patterns with a semicolon, e.g., *.pdf;*.docx. Use :documents: for every document and readable text file, :text: for readable text files only, or * for every file.")" AdornmentIcon="@Icons.Material.Filled.FilterAlt" Adornment="Adornment.Start" Variant="Variant.Outlined" Margin="Margin.Normal" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<RestoreDefaultButton Text="@T("Restore default patterns")" Disabled="@this.isProcessingBatch" OnClick="@this.RestoreDefaultFilePatterns" Class="mb-1"/>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Supported audio and video files are transcribed automatically without an additional dialog. Each transcript is stored next to its media file as '<media-file>.transcript.md' and reused when an interrupted run is continued.")
</MudJustifiedText>
<MudStack Class="mb-3">
<MudButton OnClick="@this.ToggleExpertSettings" Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Tune">
@(this.showExpertSettings ? T("Hide Expert Settings") : T("Show Expert Settings"))
</MudButton>
<MudDivider/>
<MudCollapse Expanded="@this.showExpertSettings" Class="@this.GetExpertStyles">
<MudTextSwitch Label="@T("Include hidden files and folders?")" Disabled="@this.isProcessingBatch" Value="@this.includeHiddenFilesAndFolders" ValueChanged="@(v => this.includeHiddenFilesAndFolders = v)" LabelOn="@T("Yes, process hidden files and folders as well")" LabelOff="@T("No, ignore hidden files and folders")"/>
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@T("Hidden are files and folders whose names start with a dot, such as .git or .env, and those hidden by the operating system. A file pattern which starts with a dot, such as .gitignore, selects such files anyway.")
</MudJustifiedText>
@* Without an icon: in a field of several lines, it floats in the middle of the text. *@
<MudTextField T="string" @bind-Text="@this.excludePatterns" Validation="@this.ValidateExcludePatterns" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("Excluded files and folders")" Lines="@FileExclusions.DEFAULT_LINE_COUNT" AutoGrow="@true" MaxLines="@FileExclusions.MAX_LINE_COUNT" HelperText="@T("These files and folders are left out, even when a file pattern selects them. Separate entries with a semicolon or a line break. End an entry with a slash to exclude every folder of that name, e.g., node_modules/. Other entries name files, e.g., *.min.js.")" Variant="Variant.Outlined" Margin="Margin.Normal" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<RestoreDefaultButton Text="@T("Restore default")" Disabled="@this.isProcessingBatch" OnClick="@this.RestoreDefaultExcludePatterns" Class="mb-1"/>
<MudNumericField T="int" @bind-Value="@this.maxFileSizeMegabytes" Min="@FolderFileSelection.SMALLEST_MAX_FILE_SIZE_MEGABYTES" Immediate="@true" Disabled="@this.isProcessingBatch" Label="@T("Ignore files larger than")" HelperText="@T("Matching files above this size are left out, e.g., images of virtual machines.")" Adornment="Adornment.End" AdornmentText="MB" Variant="Variant.Outlined" Margin="Margin.Normal"/>
</MudCollapse>
</MudStack>
<MudText Typo="Typo.h5" Class="mb-3 mt-6">
@T("Instructions")
</MudText>
@@ -269,4 +284,11 @@ else
}
</tbody>
</MudSimpleTable>
@if (this.numTooLargeFiles > 0)
{
<MudJustifiedText Typo="Typo.body2" Class="mb-3">
@string.Format(T("{0} matching files were left out because they are larger than {1}."), this.numTooLargeFiles, FolderFileSelection.MegabytesToBytes(this.maxFileSizeMegabytes).FileSize())
</MudJustifiedText>
}
}
@@ -8,7 +8,10 @@ public partial class AssistantBatchProcessing
private static readonly AssistantSessionStateKey<string> INPUT_DIRECTORY_STATE_KEY = new(nameof(inputDirectory));
private static readonly AssistantSessionStateKey<string> OUTPUT_DIRECTORY_STATE_KEY = new(nameof(outputDirectory));
private static readonly AssistantSessionStateKey<string> FILE_PATTERNS_STATE_KEY = new(nameof(filePatterns));
private static readonly AssistantSessionStateKey<string> EXCLUDE_PATTERNS_STATE_KEY = new(nameof(excludePatterns));
private static readonly AssistantSessionStateKey<int> MAX_FILE_SIZE_MEGABYTES_STATE_KEY = new(nameof(maxFileSizeMegabytes));
private static readonly AssistantSessionStateKey<bool> INCLUDE_SUBDIRECTORIES_STATE_KEY = new(nameof(includeSubdirectories));
private static readonly AssistantSessionStateKey<bool> INCLUDE_HIDDEN_FILES_AND_FOLDERS_STATE_KEY = new(nameof(includeHiddenFilesAndFolders));
private static readonly AssistantSessionStateKey<BatchProcessingPromptSource> PROMPT_SOURCE_STATE_KEY = new(nameof(promptSource));
private static readonly AssistantSessionStateKey<string> FREE_PROMPT_STATE_KEY = new(nameof(freePrompt));
private static readonly AssistantSessionStateKey<string> IMPORTED_PROMPT_STATE_KEY = new(nameof(importedPrompt));
@@ -28,6 +31,7 @@ public partial class AssistantBatchProcessing
private static readonly AssistantSessionStateKey<bool> IS_PROCESSING_BATCH_STATE_KEY = new(nameof(isProcessingBatch));
private static readonly AssistantSessionStateKey<bool> HAS_REPORTED_WRITE_FAILURE_STATE_KEY = new(nameof(hasReportedWriteFailure));
private static readonly AssistantSessionStateKey<int> NUM_PROCESSED_FILES_STATE_KEY = new(nameof(numProcessedFiles));
private static readonly AssistantSessionStateKey<int> NUM_TOO_LARGE_FILES_STATE_KEY = new(nameof(numTooLargeFiles));
private static readonly AssistantSessionStateKey<int> PAUSE_BEFORE_NEXT_FILE_SECONDS_STATE_KEY = new(nameof(pauseBeforeNextFileSeconds));
/// <inheritdoc />
@@ -36,7 +40,10 @@ public partial class AssistantBatchProcessing
state.Set(INPUT_DIRECTORY_STATE_KEY, this.inputDirectory);
state.Set(OUTPUT_DIRECTORY_STATE_KEY, this.outputDirectory);
state.Set(FILE_PATTERNS_STATE_KEY, this.filePatterns);
state.Set(EXCLUDE_PATTERNS_STATE_KEY, this.excludePatterns);
state.Set(MAX_FILE_SIZE_MEGABYTES_STATE_KEY, this.maxFileSizeMegabytes);
state.Set(INCLUDE_SUBDIRECTORIES_STATE_KEY, this.includeSubdirectories);
state.Set(INCLUDE_HIDDEN_FILES_AND_FOLDERS_STATE_KEY, this.includeHiddenFilesAndFolders);
state.Set(PROMPT_SOURCE_STATE_KEY, this.promptSource);
state.Set(FREE_PROMPT_STATE_KEY, this.freePrompt);
state.Set(IMPORTED_PROMPT_STATE_KEY, this.importedPrompt);
@@ -56,6 +63,7 @@ public partial class AssistantBatchProcessing
state.Set(IS_PROCESSING_BATCH_STATE_KEY, this.isProcessingBatch);
state.Set(HAS_REPORTED_WRITE_FAILURE_STATE_KEY, this.hasReportedWriteFailure);
state.Set(NUM_PROCESSED_FILES_STATE_KEY, this.numProcessedFiles);
state.Set(NUM_TOO_LARGE_FILES_STATE_KEY, this.numTooLargeFiles);
state.Set(PAUSE_BEFORE_NEXT_FILE_SECONDS_STATE_KEY, this.pauseBeforeNextFileSeconds);
}
@@ -65,7 +73,10 @@ public partial class AssistantBatchProcessing
state.Restore(INPUT_DIRECTORY_STATE_KEY, value => this.inputDirectory = value);
state.Restore(OUTPUT_DIRECTORY_STATE_KEY, value => this.outputDirectory = value);
state.Restore(FILE_PATTERNS_STATE_KEY, value => this.filePatterns = value);
state.Restore(EXCLUDE_PATTERNS_STATE_KEY, value => this.excludePatterns = value);
state.Restore(MAX_FILE_SIZE_MEGABYTES_STATE_KEY, value => this.maxFileSizeMegabytes = value);
state.Restore(INCLUDE_SUBDIRECTORIES_STATE_KEY, value => this.includeSubdirectories = value);
state.Restore(INCLUDE_HIDDEN_FILES_AND_FOLDERS_STATE_KEY, value => this.includeHiddenFilesAndFolders = value);
state.Restore(PROMPT_SOURCE_STATE_KEY, value => this.promptSource = value);
state.Restore(FREE_PROMPT_STATE_KEY, value => this.freePrompt = value);
state.Restore(IMPORTED_PROMPT_STATE_KEY, value => this.importedPrompt = value);
@@ -89,6 +100,7 @@ public partial class AssistantBatchProcessing
state.Restore(IS_PROCESSING_BATCH_STATE_KEY, value => this.isProcessingBatch = value);
state.Restore(HAS_REPORTED_WRITE_FAILURE_STATE_KEY, value => this.hasReportedWriteFailure = value);
state.Restore(NUM_PROCESSED_FILES_STATE_KEY, value => this.numProcessedFiles = value);
state.Restore(NUM_TOO_LARGE_FILES_STATE_KEY, value => this.numTooLargeFiles = value);
state.Restore(PAUSE_BEFORE_NEXT_FILE_SECONDS_STATE_KEY, value => this.pauseBeforeNextFileSeconds = value);
}
@@ -1,4 +1,3 @@
using System.IO.Enumeration;
namespace AIStudio.Assistants.BatchProcessing;
@@ -15,35 +14,9 @@ public partial class AssistantBatchProcessing
return null;
}
private string? ValidateFilePatterns(string patterns)
{
if (string.IsNullOrWhiteSpace(patterns))
return T("Please provide at least one file pattern, e.g., *.pdf. Separate multiple patterns with a semicolon.");
private string? ValidateFilePatterns(string patterns) => FileNamePatterns.Validate(patterns);
var individualPatterns = patterns.Split(';');
if (individualPatterns.Any(string.IsNullOrWhiteSpace))
return T("Please remove empty file patterns. Separate valid patterns with a single semicolon.");
foreach (var patternEntry in individualPatterns)
{
var pattern = patternEntry.Trim();
if (pattern.Contains("**", StringComparison.Ordinal))
return T("Please use only single asterisks as wildcards, e.g., *.pdf or report-*.docx.");
if (pattern is "." or ".."
|| pattern.EndsWith("..", StringComparison.Ordinal)
|| pattern.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, '/', '\\']) >= 0)
return T("Please use file name patterns without folder paths, e.g., *.pdf or report-*.docx.");
var invalidCharacters = Path.GetInvalidFileNameChars()
.Where(character => character is not '*' and not '?')
.ToArray();
if (pattern.IndexOfAny(invalidCharacters) >= 0)
return T("One of the file patterns contains an invalid character.");
}
return null;
}
private string? ValidateExcludePatterns(string patterns) => FileExclusions.Validate(patterns);
private string? ValidateCsvFileName(string fileName)
{
@@ -107,12 +80,9 @@ public partial class AssistantBatchProcessing
private IReadOnlyList<string> FindInputFiles(string resolvedOutputDirectory)
{
var patterns = this.filePatterns
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
var searchOption = this.includeSubdirectories ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
var selection = new FolderFileSelection(FileNamePatterns.Parse(this.filePatterns), FileExclusions.Parse(this.excludePatterns), this.includeSubdirectories, this.includeHiddenFilesAndFolders, this.maxFileSizeMegabytes);
var files = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
this.numTooLargeFiles = 0;
var normalizedInputDirectory = TrimDirectorySeparator(Path.GetFullPath(this.inputDirectory));
var normalizedOutputDirectory = TrimDirectorySeparator(Path.GetFullPath(resolvedOutputDirectory));
@@ -127,55 +97,46 @@ public partial class AssistantBatchProcessing
// would also exclude a document named 'output-notes.md':
var outputDirectoryPrefix = normalizedOutputDirectory + Path.DirectorySeparatorChar;
foreach (var pattern in patterns)
foreach (var file in selection.EnumerateCandidates(
this.inputDirectory,
includeDirectory: directory => !isOutputSeparateFolder
|| !string.Equals(TrimDirectorySeparator(Path.GetFullPath(directory)), normalizedOutputDirectory, StringComparison.OrdinalIgnoreCase),
reportInaccessibleDirectory: (directory, exception) =>
throw new IOException($"Could not access directory '{directory}'.", exception)))
{
foreach (var file in Directory.EnumerateFiles(this.inputDirectory, pattern, searchOption))
var normalizedFile = Path.GetFullPath(file);
if (!selection.Selects(normalizedFile) || IsTranscriptArtifact(normalizedFile))
continue;
if (isOutputSeparateFolder)
{
var normalizedFile = Path.GetFullPath(file);
if (IsTranscriptArtifact(normalizedFile))
if (normalizedFile.StartsWith(outputDirectoryPrefix, StringComparison.OrdinalIgnoreCase))
continue;
if (isOutputSeparateFolder)
{
if (normalizedFile.StartsWith(outputDirectoryPrefix, StringComparison.OrdinalIgnoreCase))
continue;
}
else if (this.IsOwnOutputArtifact(normalizedFile))
continue;
// On Windows, a pattern with a three-character extension also
// matches longer extensions: '*.pdf' also returns 'report.pdfx'.
// We therefore check the pattern ourselves:
if (!MatchesAnyPattern(normalizedFile, patterns))
continue;
files.Add(normalizedFile);
}
else if (this.IsOwnOutputArtifact(normalizedFile))
continue;
//
// Left out without a status of its own: the file never becomes part of the run. The
// line below the file list tells the user, and the log names the limit:
//
if (selection.IsTooLarge(new FileInfo(normalizedFile)))
{
this.numTooLargeFiles++;
continue;
}
files.Add(normalizedFile);
}
if (this.numTooLargeFiles > 0)
this.Logger.LogInformation("Left out {NumTooLargeFiles} matching file(s) in '{InputDirectory}' from the batch run because they are larger than {MaxFileSize}.", this.numTooLargeFiles, this.inputDirectory, selection.MaxFileSizeBytes.FileSize());
return [.. files];
}
private static string TrimDirectorySeparator(string path) => path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
private static bool MatchesAnyPattern(string filePath, IReadOnlyList<string> patterns)
{
var fileName = Path.GetFileName(filePath);
foreach (var pattern in patterns)
{
// A pattern may contain a folder part, which does not take part in
// matching the file name:
var namePattern = Path.GetFileName(pattern);
if (string.IsNullOrWhiteSpace(namePattern))
continue;
if (FileSystemName.MatchesSimpleExpression(namePattern, fileName))
return true;
}
return false;
}
/// <summary>
/// Checks whether a file is an output artifact of this assistant. We need
/// this when the output folder is the input folder: without it, the results
@@ -211,6 +172,13 @@ public partial class AssistantBatchProcessing
{
await this.Form!.Validate();
//
// The exclusion list sits in the expert settings, which are folded away by default. Unfold
// them, so the user sees the message next to the list:
//
if (this.ValidateExcludePatterns(this.excludePatterns) is not null)
this.showExpertSettings = true;
var instructionIssue = this.ValidateInstructionSource();
if (instructionIssue is not null)
{
@@ -236,7 +204,9 @@ public partial class AssistantBatchProcessing
if (files.Count == 0)
{
this.AddInputIssue(T("No matching files were found in the selected folder."));
this.AddInputIssue(this.numTooLargeFiles > 0
? string.Format(T("Every matching file in the selected folder is larger than {0}. Raise the size limit to process them."), FolderFileSelection.MegabytesToBytes(this.maxFileSizeMegabytes).FileSize())
: T("No matching files were found in the selected folder."));
return null;
}
@@ -82,6 +82,7 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
this.hasReportedWriteFailure = false;
this.numProcessedFiles = 0;
this.pauseBeforeNextFileSeconds = 0;
this.numTooLargeFiles = 0;
}
protected override bool MightPreselectValues()
@@ -102,7 +103,10 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
private string inputDirectory = string.Empty;
private string outputDirectory = string.Empty;
private string filePatterns = DataBatchProcessing.DEFAULT_FILE_PATTERNS;
private bool includeSubdirectories;
private string excludePatterns = FileExclusions.DEFAULT_PATTERNS;
private int maxFileSizeMegabytes = FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES;
private bool includeSubdirectories = DataBatchProcessing.DEFAULT_INCLUDE_SUBDIRECTORIES;
private bool includeHiddenFilesAndFolders = DataBatchProcessing.DEFAULT_INCLUDE_HIDDEN_FILES_AND_FOLDERS;
private BatchProcessingPromptSource promptSource = BatchProcessingPromptSource.FREE_PROMPT;
private string freePrompt = string.Empty;
private string importedPrompt = string.Empty;
@@ -120,11 +124,17 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
private readonly List<BatchProcessingFileResult> fileResults = [];
private readonly HashSet<string> usedResultFileNames = new(StringComparer.OrdinalIgnoreCase);
private bool showExpertSettings;
private bool isProcessingBatch;
private bool hasReportedWriteFailure;
private int numProcessedFiles;
private int pauseBeforeNextFileSeconds;
/// <summary>
/// How many matching files the last search left out for their size, cf. FindInputFiles.
/// </summary>
private int numTooLargeFiles;
/// <summary>
/// The header of the column of the results table that holds the AI answer.
/// </summary>
@@ -159,6 +169,12 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
private void RestoreDefaultFilePatterns() => this.filePatterns = DataBatchProcessing.DEFAULT_FILE_PATTERNS;
private void RestoreDefaultExcludePatterns() => this.excludePatterns = FileExclusions.DEFAULT_PATTERNS;
private void ToggleExpertSettings() => this.showExpertSettings = !this.showExpertSettings;
private string GetExpertStyles => this.showExpertSettings ? "border-2 border-dashed rounded pa-2" : string.Empty;
private ConfidenceLevel GetMinimumConfidenceLevel()
{
var minimumLevel = this.SettingsManager.GetMinimumConfidenceLevel(this.Component);
@@ -178,7 +194,10 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
this.inputDirectory = string.Empty;
this.outputDirectory = string.Empty;
this.filePatterns = DataBatchProcessing.DEFAULT_FILE_PATTERNS;
this.includeSubdirectories = false;
this.excludePatterns = FileExclusions.DEFAULT_PATTERNS;
this.maxFileSizeMegabytes = FolderFileSelection.DEFAULT_MAX_FILE_SIZE_MEGABYTES;
this.includeSubdirectories = DataBatchProcessing.DEFAULT_INCLUDE_SUBDIRECTORIES;
this.includeHiddenFilesAndFolders = DataBatchProcessing.DEFAULT_INCLUDE_HIDDEN_FILES_AND_FOLDERS;
this.promptSource = BatchProcessingPromptSource.FREE_PROMPT;
this.freePrompt = string.Empty;
this.promptFilePath = string.Empty;
@@ -197,7 +216,10 @@ public partial class AssistantBatchProcessing : AssistantBaseCore<SettingsDialog
this.inputDirectory = settings.InputDirectory;
this.outputDirectory = settings.OutputDirectory;
this.filePatterns = settings.FilePatterns;
this.excludePatterns = settings.ExcludePatterns;
this.maxFileSizeMegabytes = Math.Max(settings.MaxFileSizeMegabytes, FolderFileSelection.SMALLEST_MAX_FILE_SIZE_MEGABYTES);
this.includeSubdirectories = settings.IncludeSubdirectories;
this.includeHiddenFilesAndFolders = settings.IncludeHiddenFilesAndFolders;
this.promptSource = settings.PromptSource;
this.freePrompt = settings.FreePrompt;
this.promptFilePath = settings.PromptFilePath;