Added support for files without extensions

This commit is contained in:
PaulKoudelka 2026-03-31 16:41:46 +02:00
parent 099f9232d0
commit 5c1dd4e550
15 changed files with 234 additions and 264 deletions

View File

@ -6319,30 +6319,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T304
-- AI source selection with AI retrieval context validation -- AI source selection with AI retrieval context validation
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T3775725978"] = "AI source selection with AI retrieval context validation" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T3775725978"] = "AI source selection with AI retrieval context validation"
-- Executable Files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T2217313358"] = "Executable Files"
-- All Source Code Files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T2460199369"] = "All Source Code Files"
-- All Audio Files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T2575722901"] = "All Audio Files"
-- All Video Files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T2850789856"] = "All Video Files"
-- PDF Files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T3108466742"] = "PDF Files"
-- All Image Files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T4086723714"] = "All Image Files"
-- Text Files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T639143005"] = "Text Files"
-- All Office Files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T709668067"] = "All Office Files"
-- Text -- Text
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text"
@ -6352,6 +6328,12 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1063218378"] = "Office Files
-- Executable -- Executable
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1364437037"] = "Executable" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1364437037"] = "Executable"
-- Mail
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1399880782"] = "Mail"
-- Source like
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1487238587"] = "Source like"
-- Image -- Image
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1494001562"] = "Image" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1494001562"] = "Image"
@ -6373,6 +6355,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T2502277006"] = "Custom"
-- Media -- Media
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3507473059"] = "Media" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3507473059"] = "Media"
-- Source like prefix
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like prefix"
-- Document -- Document
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Document" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Document"

View File

@ -58,14 +58,11 @@ public record FileAttachment(FileAttachmentType Type, string FileName, string Fi
/// extracting the filename, and reading the file size. /// extracting the filename, and reading the file size.
/// </summary> /// </summary>
/// <param name="filePath">The full path to the file.</param> /// <param name="filePath">The full path to the file.</param>
/// <param name="allowedTypes">Optional: The allowed file types.</param>
/// <returns>A FileAttachment instance with populated properties.</returns> /// <returns>A FileAttachment instance with populated properties.</returns>
public static FileAttachment FromPath(string filePath, FileType[]? allowedTypes=null) public static FileAttachment FromPath(string filePath)
{ {
var fileName = Path.GetFileName(filePath); var fileName = Path.GetFileName(filePath);
var fileSize = File.Exists(filePath) ? new FileInfo(filePath).Length : 0; var fileSize = File.Exists(filePath) ? new FileInfo(filePath).Length : 0;
if (allowedTypes != null && !IsAllowed(filePath, allowedTypes))
return new FileAttachment(FileAttachmentType.FORBIDDEN, fileName, filePath, fileSize);
var type = DetermineFileType(filePath); var type = DetermineFileType(filePath);
return type switch return type switch
@ -79,37 +76,23 @@ public record FileAttachment(FileAttachmentType Type, string FileName, string Fi
/// <summary> /// <summary>
/// Determines the file attachment type based on the file extension. /// Determines the file attachment type based on the file extension.
/// Uses centrally defined file types from <see cref="FileTypes"/>. /// Uses centrally defined file type filters from <see cref="FileTypes"/>.
/// </summary> /// </summary>
/// <param name="filePath">The file path to analyze.</param> /// <param name="filePath">The file path to analyze.</param>
/// <returns>The corresponding FileAttachmentType.</returns> /// <returns>The corresponding FileAttachmentType.</returns>
private static FileAttachmentType DetermineFileType(string filePath) private static FileAttachmentType DetermineFileType(string filePath)
{ {
var extension = Path.GetExtension(filePath).TrimStart('.').ToLowerInvariant(); if (FileTypes.IsAllowedPath(filePath, FileTypes.EXECUTABLES))
return FileAttachmentType.FORBIDDEN;
// Check if it's an image file: if (FileTypes.IsAllowedPath(filePath, FileTypes.IMAGE))
if (FileTypes.OnlyAllowTypes(FileTypes.IMAGE).Contains(extension))
{
return FileAttachmentType.IMAGE; return FileAttachmentType.IMAGE;
}
// Check if it's an audio file: if (FileTypes.IsAllowedPath(filePath, FileTypes.AUDIO))
if (FileTypes.OnlyAllowTypes(FileTypes.AUDIO).Contains(extension))
return FileAttachmentType.AUDIO; return FileAttachmentType.AUDIO;
// Check if it's an allowed document file (PDF, Text, or Office): return FileTypes.IsAllowedPath(filePath, FileTypes.DOCUMENT)
if (FileTypes.OnlyAllowTypes(FileTypes.DOCUMENT).Contains(extension)) ? FileAttachmentType.DOCUMENT
{ : FileAttachmentType.FORBIDDEN;
return FileAttachmentType.DOCUMENT;
}
// All other file types are forbidden:
return FileAttachmentType.FORBIDDEN;
} }
}
private static bool IsAllowed(string filePath, FileType[] allowedTypes)
{
var extension = Path.GetExtension(filePath).TrimStart('.').ToLowerInvariant();
return FileTypes.OnlyAllowTypes(allowedTypes).Contains(extension);
}
}

View File

@ -48,9 +48,6 @@ public partial class AttachDocuments : MSGComponentBase
[Parameter] [Parameter]
public bool UseSmallForm { get; set; } public bool UseSmallForm { get; set; }
[Parameter]
public FileType[]? AllowedFileTypes { get; set; }
/// <summary> /// <summary>
/// When true, validate media file types before attaching. Default is true. That means that /// When true, validate media file types before attaching. Default is true. That means that
/// the user cannot attach unsupported media file types when the provider or model does not /// the user cannot attach unsupported media file types when the provider or model does not
@ -184,7 +181,7 @@ public partial class AttachDocuments : MSGComponentBase
{ {
if(!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, path, this.ValidateMediaFileTypes, this.Provider)) if(!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, path, this.ValidateMediaFileTypes, this.Provider))
continue; continue;
this.DocumentPaths.Add(FileAttachment.FromPath(path, this.AllowedFileTypes)); this.DocumentPaths.Add(FileAttachment.FromPath(path));
} }
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths); await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);
@ -228,7 +225,7 @@ public partial class AttachDocuments : MSGComponentBase
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, selectedFilePath, this.ValidateMediaFileTypes, this.Provider)) if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, selectedFilePath, this.ValidateMediaFileTypes, this.Provider))
continue; continue;
this.DocumentPaths.Add(FileAttachment.FromPath(selectedFilePath, this.AllowedFileTypes)); this.DocumentPaths.Add(FileAttachment.FromPath(selectedFilePath));
} }
await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths); await this.DocumentPathsChanged.InvokeAsync(this.DocumentPaths);

View File

@ -23,7 +23,7 @@ public partial class SelectFile : MSGComponentBase
public string FileDialogTitle { get; set; } = "Select File"; public string FileDialogTitle { get; set; } = "Select File";
[Parameter] [Parameter]
public FileType[]? Filter { get; set; } public FileTypeFilter[]? Filter { get; set; }
[Parameter] [Parameter]
public Func<string, string?> Validation { get; set; } = _ => null; public Func<string, string?> Validation { get; set; } = _ => null;

View File

@ -1,6 +1,5 @@
@using AIStudio.Provider @using AIStudio.Provider
@using AIStudio.Provider.SelfHosted @using AIStudio.Provider.SelfHosted
@using AIStudio.Tools.Rust
@inherits MSGComponentBase @inherits MSGComponentBase
<MudDialog> <MudDialog>
@ -125,7 +124,6 @@
Validation="@this.providerValidation.ValidatingInstanceName" Validation="@this.providerValidation.ValidatingInstanceName"
UserAttributes="@SPELLCHECK_ATTRIBUTES" UserAttributes="@SPELLCHECK_ATTRIBUTES"
/> />
<AttachDocuments Name="File Attachments" Layer="@DropLayers.PAGES" @bind-DocumentPaths="@this.chatDocumentPaths" CatchAllDocuments="true" UseSmallForm="true" ValidateMediaFileTypes="true" AllowedFileTypes="[FileTypes.JSON]"/>
</MudForm> </MudForm>
<Issues IssuesData="@this.dataIssues"/> <Issues IssuesData="@this.dataIssues"/>

View File

@ -1,4 +1,3 @@
using AIStudio.Chat;
using AIStudio.Components; using AIStudio.Components;
using AIStudio.Provider; using AIStudio.Provider;
using AIStudio.Settings; using AIStudio.Settings;
@ -97,7 +96,6 @@ public partial class EmbeddingProviderDialog : MSGComponentBase, ISecretId
private readonly List<Model> availableModels = new(); private readonly List<Model> availableModels = new();
private readonly Encryption encryption = Program.ENCRYPTION; private readonly Encryption encryption = Program.ENCRYPTION;
private readonly ProviderValidation providerValidation; private readonly ProviderValidation providerValidation;
private HashSet<FileAttachment> chatDocumentPaths = [];
public EmbeddingProviderDialog() public EmbeddingProviderDialog()
{ {

View File

@ -6321,29 +6321,47 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T304
-- AI-based data source selection with AI retrieval context validation -- AI-based data source selection with AI retrieval context validation
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T3775725978"] = "KI-basierte Datenquellen-Auswahl mit Validierung des Abrufkontexts" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T3775725978"] = "KI-basierte Datenquellen-Auswahl mit Validierung des Abrufkontexts"
-- Executable Files -- Text
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T2217313358"] = "Ausführbare Dateien" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text"
-- All Source Code Files -- Office Files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T2460199369"] = "Alle Quellcodedateien" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1063218378"] = "Office-Dateien"
-- All Audio Files -- Executable
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T2575722901"] = "Alle Audiodateien" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1364437037"] = "Ausführbare Dateien"
-- All Video Files -- Mail
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T2850789856"] = "Alle Videodateien" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1399880782"] = "E-Mail"
-- PDF Files -- Source like
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T3108466742"] = "PDF-Dateien" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1487238587"] = "Source Code ähnlich"
-- All Image Files -- Image
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T4086723714"] = "Alle Bilddateien" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1494001562"] = "Bild"
-- Text Files -- Video
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T639143005"] = "Textdateien" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1533528076"] = "Video"
-- All Office Files -- Source Code
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T709668067"] = "Alle Office-Dateien" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1569048941"] = "Quellcode"
-- Config
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1779622119"] = "Konfiguration"
-- Audio
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T2291602489"] = "Audio"
-- Custom
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T2502277006"] = "Benutzerdefiniert"
-- Media
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3507473059"] = "Medien"
-- Source like prefix
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source Code ähnlicher Prefix"
-- Document
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Dokument"
-- Pandoc Installation -- Pandoc Installation
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc-Installation" UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc-Installation"

View File

@ -6321,29 +6321,47 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T304
-- AI-based data source selection with AI retrieval context validation -- AI-based data source selection with AI retrieval context validation
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T3775725978"] = "AI-based data source selection with AI retrieval context validation" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T3775725978"] = "AI-based data source selection with AI retrieval context validation"
-- Executable Files -- Text
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T2217313358"] = "Executable Files" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1041509726"] = "Text"
-- All Source Code Files -- Office Files
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T2460199369"] = "All Source Code Files" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1063218378"] = "Office Files"
-- All Audio Files -- Executable
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T2575722901"] = "All Audio Files" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1364437037"] = "Executable"
-- All Video Files -- Mail
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T2850789856"] = "All Video Files" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1399880782"] = "Mail"
-- PDF Files -- Source like
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T3108466742"] = "PDF Files" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1487238587"] = "Source like"
-- All Image Files -- Image
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T4086723714"] = "All Image Files" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1494001562"] = "Image"
-- Text Files -- Video
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T639143005"] = "Text Files" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1533528076"] = "Video"
-- All Office Files -- Source Code
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPEFILTER::T709668067"] = "All Office Files" UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1569048941"] = "Source Code"
-- Config
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T1779622119"] = "Config"
-- Audio
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T2291602489"] = "Audio"
-- Custom
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T2502277006"] = "Custom"
-- Media
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T3507473059"] = "Media"
-- Source like prefix
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T378481461"] = "Source like prefix"
-- Document
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RUST::FILETYPES::T4165204724"] = "Document"
-- Pandoc Installation -- Pandoc Installation
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc Installation" UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::PANDOCAVAILABILITYSERVICE::T185447014"] = "Pandoc Installation"

View File

@ -1,41 +0,0 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// Represents a file type that can optionally contain child file types.
/// Use the static helpers <see cref="Leaf"/>, <see cref="Parent"/> and <see cref="Composite"/> to build readable trees.
/// </summary>
/// <param name="FilterName">Display name of the type (e.g., "Document").</param>
/// <param name="FilterExtensions">File extensions belonging to this type (without dot).</param>
/// <param name="Children">Nested file types that are included when this type is selected.</param>
public sealed record FileType(string FilterName, string[] FilterExtensions, IReadOnlyList<FileType> Children)
{
/// <summary>
/// Factory for a leaf node.
/// Example: <c>FileType.Leaf(".NET", "cs", "razor")</c>
/// </summary>
public static FileType Leaf(string name, params string[] extensions) =>
new(name, extensions, []);
/// <summary>
/// Factory for a parent node that only has children.
/// Example: <c>FileType.Parent("Source Code", dotnet, java)</c>
/// </summary>
public static FileType Parent(string name, params FileType[]? children) =>
new(name, [], children ?? []);
/// <summary>
/// Factory for a composite node that has its own extensions in addition to children.
/// </summary>
public static FileType Composite(string name, string[] extensions, params FileType[] children) =>
new(name, extensions, children);
/// <summary>
/// Collects all extensions for this type, including children.
/// </summary>
public IEnumerable<string> FlattenExtensions()
{
return this.FilterExtensions
.Concat(this.Children.SelectMany(child => child.FlattenExtensions()))
.Distinct(StringComparer.OrdinalIgnoreCase);
}
}

View File

@ -1,80 +1,49 @@
// ReSharper disable NotAccessedPositionalProperty.Global
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.Rust; namespace AIStudio.Tools.Rust;
/// <summary> /// <summary>
/// Represents a file type filter for file selection dialogs. /// Represents a file type that can optionally contain child file types.
/// Use the static helpers <see cref="Leaf"/>, <see cref="Parent"/> and <see cref="Composite"/> to build readable trees.
/// </summary> /// </summary>
/// <param name="FilterName">The name of the filter.</param> /// <param name="FilterName">Display name of the type (e.g., "Document").</param>
/// <param name="FilterExtensions">The file extensions associated with the filter.</param> /// <param name="FilterExtensions">File extensions belonging to this type (without dot).</param>
public readonly record struct FileTypeFilter(string FilterName, string[] FilterExtensions) /// <param name="Children">Nested file types that are included when this type is selected.</param>
public sealed record FileTypeFilter(string FilterName, string[] FilterExtensions, IReadOnlyList<FileTypeFilter> Children)
{ {
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(FileTypeFilter).Namespace, nameof(FileTypeFilter)); /// <summary>
/// Factory for a leaf node.
/// Example: <c>FileType.Leaf(".NET", "cs", "razor")</c>
/// </summary>
public static FileTypeFilter Leaf(string name, params string[] extensions) =>
new(name, extensions, []);
/// <summary>
/// Factory for a parent node that only has children.
/// Example: <c>FileType.Parent("Source Code", dotnet, java)</c>
/// </summary>
public static FileTypeFilter Parent(string name, params FileTypeFilter[]? children) =>
new(name, [], children ?? []);
/// <summary>
/// Factory for a composite node that has its own extensions in addition to children.
/// </summary>
public static FileTypeFilter Composite(string name, string[] extensions, params FileTypeFilter[] children) =>
new(name, extensions, children);
public static FileTypeFilter PDF => new(TB("PDF Files"), ["pdf"]); /// <summary>
/// Collects all extensions for this type, including children.
/// </summary>
public IEnumerable<string> FlattenExtensions()
{
return this.FilterExtensions
.Concat(this.Children.SelectMany(child => child.FlattenExtensions()))
.Distinct(StringComparer.OrdinalIgnoreCase);
}
public static FileTypeFilter Text => new(TB("Text Files"), ["txt", "md"]); public bool ContainsType(FileTypeFilter target)
{
public static FileTypeFilter AllOffice => new(TB("All Office Files"), ["docx", "xlsx", "pptx", "doc", "xls", "ppt", "pdf"]); if (this == target)
return true;
public static FileTypeFilter AllImages => new(TB("All Image Files"), ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "svg", "webp", "heic"]);
return this.Children.Any(child => child.ContainsType(target));
public static FileTypeFilter AllVideos => new(TB("All Video Files"), ["mp4", "m4v", "avi", "mkv", "mov", "wmv", "flv", "webm"]); }
public static FileTypeFilter AllAudio => new(TB("All Audio Files"), ["mp3", "wav", "wave", "aac", "flac", "ogg", "m4a", "wma", "alac", "aiff", "m4b"]);
public static FileTypeFilter AllSourceCode => new(TB("All Source Code Files"),
[
// .NET
"cs", "vb", "fs", "razor", "aspx", "cshtml", "csproj",
// Java:
"java",
// Python:
"py",
// JavaScript/TypeScript:
"js", "ts",
// C/C++:
"c", "cpp", "h", "hpp",
// Ruby:
"rb",
// Go:
"go",
// Rust:
"rs",
// Lua:
"lua",
// PHP:
"php",
// HTML/CSS:
"html", "css",
// Swift/Kotlin:
"swift", "kt",
// Shell scripts:
"sh", "bash",
// Logging files:
"log",
// JSON/YAML/XML:
"json", "yaml", "yml", "xml",
// Config files:
"ini", "cfg", "toml", "plist",
]);
public static FileTypeFilter Executables => new(TB("Executable Files"), ["exe", "app", "bin", "appimage"]);
} }

View File

@ -8,77 +8,123 @@ namespace AIStudio.Tools.Rust;
/// </summary> /// </summary>
public static class FileTypes public static class FileTypes
{ {
private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(FileType).Namespace, nameof(FileType)); private static string TB(string fallbackEn) => I18N.I.T(fallbackEn, typeof(FileTypeFilter).Namespace, nameof(FileTypeFilter));
// Keep SOURCE_LIKE in the same leaf style as the other file types.
// These values are not sufficient for Dockerfile-style files without extensions,
// therefore IsAllowedSourceLikeFileName is still required for real matching.
public static readonly FileTypeFilter SOURCE_LIKE_FILE_NAMES = FileTypeFilter.Leaf(TB("Source like"),
"Dockerfile", "Containerfile", "Jenkinsfile", "Makefile", "GNUmakefile", "Procfile", "Vagrantfile",
"Tiltfile", "Justfile", "Brewfile", "Caddyfile", "Gemfile", "Podfile", "Fastfile", "Appfile", "Rakefile", "Dangerfile",
"BUILD", "WORKSPACE", "BUCK");
public static readonly FileTypeFilter SOURCE_LIKE_FILE_NAME_PREFIXES = FileTypeFilter.Leaf(TB("Source like prefix"),
"Dockerfile", "Containerfile", "Jenkinsfile", "Procfile", "Caddyfile");
// Source code hierarchy: SourceCode -> (.NET, Java, Python, Web, C/C++, Config, ...) // Source code hierarchy: SourceCode -> (.NET, Java, Python, Web, C/C++, Config, ...)
public static readonly FileType DOTNET = FileType.Leaf(".NET", "cs", "razor", "vb", "fs", "aspx", "cshtml", "csproj"); public static readonly FileTypeFilter DOTNET = FileTypeFilter.Leaf(".NET", "cs", "razor", "vb", "fs", "aspx", "cshtml", "csproj");
public static readonly FileType JAVA = FileType.Leaf("Java", "java"); public static readonly FileTypeFilter JAVA = FileTypeFilter.Leaf("Java", "java");
public static readonly FileType PYTHON = FileType.Leaf("Python", "py"); public static readonly FileTypeFilter PYTHON = FileTypeFilter.Leaf("Python", "py");
public static readonly FileType JAVASCRIPT = FileType.Leaf("JavaScript/TypeScript", "js", "ts"); public static readonly FileTypeFilter JAVASCRIPT = FileTypeFilter.Leaf("JavaScript/TypeScript", "js", "ts");
public static readonly FileType CFAMILY = FileType.Leaf("C/C++", "c", "cpp", "h", "hpp"); public static readonly FileTypeFilter CFAMILY = FileTypeFilter.Leaf("C/C++", "c", "cpp", "h", "hpp");
public static readonly FileType RUBY = FileType.Leaf("Ruby", "rb"); public static readonly FileTypeFilter RUBY = FileTypeFilter.Leaf("Ruby", "rb");
public static readonly FileType GO = FileType.Leaf("Go", "go"); public static readonly FileTypeFilter GO = FileTypeFilter.Leaf("Go", "go");
public static readonly FileType RUST = FileType.Leaf("Rust", "rs"); public static readonly FileTypeFilter RUST = FileTypeFilter.Leaf("Rust", "rs");
public static readonly FileType LUA = FileType.Leaf("Lua", "lua"); public static readonly FileTypeFilter LUA = FileTypeFilter.Leaf("Lua", "lua");
public static readonly FileType PHP = FileType.Leaf("PHP", "php"); public static readonly FileTypeFilter PHP = FileTypeFilter.Leaf("PHP", "php");
public static readonly FileType WEB = FileType.Leaf("HTML/CSS", "html", "css"); public static readonly FileTypeFilter WEB = FileTypeFilter.Leaf("HTML/CSS", "html", "css");
public static readonly FileType APP = FileType.Leaf("Swift/Kotlin", "swift", "kt"); public static readonly FileTypeFilter APP = FileTypeFilter.Leaf("Swift/Kotlin", "swift", "kt");
public static readonly FileType SHELL = FileType.Leaf("Shell", "sh", "bash", "zsh"); public static readonly FileTypeFilter SHELL = FileTypeFilter.Leaf("Shell", "sh", "bash", "zsh");
public static readonly FileType LOG = FileType.Leaf("Log", "log"); public static readonly FileTypeFilter LOG = FileTypeFilter.Leaf("Log", "log");
public static readonly FileType JSON = FileType.Leaf("JSON", "json"); public static readonly FileTypeFilter JSON = FileTypeFilter.Leaf("JSON", "json");
public static readonly FileType XML = FileType.Leaf("XML", "xml"); public static readonly FileTypeFilter XML = FileTypeFilter.Leaf("XML", "xml");
public static readonly FileType YAML = FileType.Leaf("YAML", "yaml", "yml"); public static readonly FileTypeFilter YAML = FileTypeFilter.Leaf("YAML", "yaml", "yml");
public static readonly FileType CONFIG = FileType.Leaf(TB("Config"), "ini", "cfg", "toml", "plist"); public static readonly FileTypeFilter CONFIG = FileTypeFilter.Leaf(TB("Config"), "ini", "cfg", "toml", "plist");
public static readonly FileType SOURCE_CODE = FileType.Parent(TB("Source Code"), public static readonly FileTypeFilter SOURCE_CODE = FileTypeFilter.Parent(TB("Source Code"),
DOTNET, JAVA, PYTHON, JAVASCRIPT, CFAMILY, RUBY, GO, RUST, LUA, PHP, WEB, APP, SHELL, LOG, JSON, XML, YAML, CONFIG); DOTNET, JAVA, PYTHON, JAVASCRIPT, CFAMILY, RUBY, GO, RUST, LUA, PHP, WEB, APP, SHELL, LOG, JSON, XML, YAML, CONFIG, SOURCE_LIKE_FILE_NAMES, SOURCE_LIKE_FILE_NAME_PREFIXES);
// Document hierarchy // Document hierarchy
public static readonly FileType PDF = FileType.Leaf("PDF", "pdf"); public static readonly FileTypeFilter PDF = FileTypeFilter.Leaf("PDF", "pdf");
public static readonly FileType TEXT = FileType.Leaf(TB("Text"), "txt", "md"); public static readonly FileTypeFilter TEXT = FileTypeFilter.Leaf(TB("Text"), "txt", "md");
public static readonly FileType MS_WORD = FileType.Leaf("Microsoft Word", "docx"); public static readonly FileTypeFilter MS_WORD = FileTypeFilter.Leaf("Microsoft Word", "docx");
public static readonly FileType WORD = FileType.Composite("Word", ["docx"], MS_WORD); public static readonly FileTypeFilter WORD = FileTypeFilter.Composite("Word", ["doc"], MS_WORD);
public static readonly FileType EXCEL = FileType.Leaf("Excel", "xls", "xlsx"); public static readonly FileTypeFilter EXCEL = FileTypeFilter.Leaf("Excel", "xls", "xlsx");
public static readonly FileType POWER_POINT = FileType.Leaf("PowerPoint", "ppt", "pptx"); public static readonly FileTypeFilter POWER_POINT = FileTypeFilter.Leaf("PowerPoint", "ppt", "pptx");
public static readonly FileTypeFilter MAIL = FileTypeFilter.Leaf(TB("Mail"), "eml", "msg", "mbox");
public static readonly FileType OFFICE_FILES = FileType.Parent(TB("Office Files"),
public static readonly FileTypeFilter OFFICE_FILES = FileTypeFilter.Parent(TB("Office Files"),
WORD, EXCEL, POWER_POINT, PDF); WORD, EXCEL, POWER_POINT, PDF);
public static readonly FileType DOCUMENT = FileType.Parent(TB("Document"), public static readonly FileTypeFilter DOCUMENT = FileTypeFilter.Parent(TB("Document"),
TEXT, OFFICE_FILES, SOURCE_CODE); TEXT, OFFICE_FILES, SOURCE_CODE, MAIL);
// Media hierarchy // Media hierarchy
public static readonly FileType IMAGE = FileType.Leaf(TB("Image"), public static readonly FileTypeFilter IMAGE = FileTypeFilter.Leaf(TB("Image"),
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "svg", "webp", "heic"); "jpg", "jpeg", "png", "gif", "bmp", "tiff", "svg", "webp", "heic");
public static readonly FileType AUDIO = FileType.Leaf(TB("Audio"), public static readonly FileTypeFilter AUDIO = FileTypeFilter.Leaf(TB("Audio"),
"mp3", "wav", "wave", "aac", "flac", "ogg", "m4a", "wma", "alac", "aiff", "m4b"); "mp3", "wav", "wave", "aac", "flac", "ogg", "m4a", "wma", "alac", "aiff", "m4b");
public static readonly FileType VIDEO = FileType.Leaf(TB("Video"), public static readonly FileTypeFilter VIDEO = FileTypeFilter.Leaf(TB("Video"),
"mp4", "m4v", "avi", "mkv", "mov", "wmv", "flv", "webm"); "mp4", "m4v", "avi", "mkv", "mov", "wmv", "flv", "webm");
public static readonly FileType MEDIA = FileType.Parent(TB("Media"), IMAGE, AUDIO, VIDEO); public static readonly FileTypeFilter MEDIA = FileTypeFilter.Parent(TB("Media"), IMAGE, AUDIO, VIDEO);
// Other standalone types // Other standalone types
public static readonly FileType EXECUTABLES = FileType.Leaf(TB("Executable"), "exe", "app", "bin", "appimage"); public static readonly FileTypeFilter EXECUTABLES = FileTypeFilter.Leaf(TB("Executable"), "exe", "app", "bin", "appimage");
public static FileTypeFilter? AsOneFileType(params FileTypeFilter[]? types)
{
if (types == null || types.Length == 0)
return null;
if (types.Length == 1) return types[0];
/// <summary> return FileTypeFilter.Composite(TB("Custom"), OnlyAllowTypes(types));
/// Builds a distinct, lower-cased list of extensions allowed for the provided types. }
/// Accepts both composite types (e.g., Document) and leaves (e.g., Pdf).
/// </summary> public static string[] OnlyAllowTypes(params FileTypeFilter[] types)
public static string[] OnlyAllowTypes(params FileType[] types)
{ {
if (types.Length == 0) if (types.Length == 0)
return []; return [];
return types return types
.Where(t => t != SOURCE_LIKE_FILE_NAMES && t != SOURCE_LIKE_FILE_NAME_PREFIXES)
.SelectMany(t => t.FlattenExtensions()) .SelectMany(t => t.FlattenExtensions())
.Select(ext => ext.ToLowerInvariant()) .Select(ext => ext.ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray(); .ToArray();
} }
public static FileType? AsOneFileType(params FileType[]? types) /// <summary>
/// Validates a file path against the provided filters.
/// Supports extension-based matching and source-like file names (e.g. Dockerfile).
/// </summary>
public static bool IsAllowedPath(string filePath, params FileTypeFilter[]? types)
{ {
if (types == null || types.Length == 0) if (types == null || types.Length == 0 || string.IsNullOrWhiteSpace(filePath))
return null; return false;
return FileType.Composite(TB("Custom"), OnlyAllowTypes(types));
var extension = Path.GetExtension(filePath).TrimStart('.');
if (!string.IsNullOrWhiteSpace(extension))
{
if (OnlyAllowTypes(types).Contains(extension, StringComparer.OrdinalIgnoreCase))
return true;
}
var fileName = Path.GetFileName(filePath);
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
if (types.Any(t => t.ContainsType(SOURCE_LIKE_FILE_NAMES)))
{
if (SOURCE_LIKE_FILE_NAMES.FilterExtensions.Contains(fileName)) return true;
}
if (types.Any(t => t.ContainsType(SOURCE_LIKE_FILE_NAME_PREFIXES))){
if (SOURCE_LIKE_FILE_NAME_PREFIXES.FilterExtensions.Any(prefix => fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) return true;
}
return false;
} }
} }

View File

@ -6,5 +6,5 @@ public class SaveFileOptions
public PreviousFile? PreviousFile { get; init; } public PreviousFile? PreviousFile { get; init; }
public FileType? Filter { get; init; } public FileTypeFilter? Filter { get; init; }
} }

View File

@ -6,5 +6,5 @@ public sealed class SelectFileOptions
public PreviousFile? PreviousFile { get; init; } public PreviousFile? PreviousFile { get; init; }
public FileType? Filter { get; init; } public FileTypeFilter? Filter { get; init; }
} }

View File

@ -17,7 +17,7 @@ public sealed partial class RustService
return await result.Content.ReadFromJsonAsync<DirectorySelectionResponse>(this.jsonRustSerializerOptions); return await result.Content.ReadFromJsonAsync<DirectorySelectionResponse>(this.jsonRustSerializerOptions);
} }
public async Task<FileSelectionResponse> SelectFile(string title, FileType[]? filter = null, string? initialFile = null) public async Task<FileSelectionResponse> SelectFile(string title, FileTypeFilter[]? filter = null, string? initialFile = null)
{ {
var payload = new SelectFileOptions var payload = new SelectFileOptions
{ {
@ -36,7 +36,7 @@ public sealed partial class RustService
return await result.Content.ReadFromJsonAsync<FileSelectionResponse>(this.jsonRustSerializerOptions); return await result.Content.ReadFromJsonAsync<FileSelectionResponse>(this.jsonRustSerializerOptions);
} }
public async Task<FilesSelectionResponse> SelectFiles(string title, FileType[]? filter = null, string? initialFile = null) public async Task<FilesSelectionResponse> SelectFiles(string title, FileTypeFilter[]? filter = null, string? initialFile = null)
{ {
var payload = new SelectFileOptions var payload = new SelectFileOptions
{ {
@ -63,7 +63,7 @@ public sealed partial class RustService
/// <param name="initialFile">An optional initial file path to pre-fill in the dialog.</param> /// <param name="initialFile">An optional initial file path to pre-fill in the dialog.</param>
/// <returns>A <see cref="FileSaveResponse"/> object containing information about whether the user canceled the /// <returns>A <see cref="FileSaveResponse"/> object containing information about whether the user canceled the
/// operation and whether the select operation was successful.</returns> /// operation and whether the select operation was successful.</returns>
public async Task<FileSaveResponse> SaveFile(string title, FileType[]? filter = null, string? initialFile = null) public async Task<FileSaveResponse> SaveFile(string title, FileTypeFilter[]? filter = null, string? initialFile = null)
{ {
var payload = new SaveFileOptions var payload = new SaveFileOptions
{ {

View File

@ -43,8 +43,7 @@ public static class FileExtensionValidation
/// <returns>True if valid, false if invalid (error/warning already sent via MessageBus).</returns> /// <returns>True if valid, false if invalid (error/warning already sent via MessageBus).</returns>
public static async Task<bool> IsExtensionValidWithNotifyAsync(UseCase useCae, string filePath, bool validateMediaFileTypes = true, Settings.Provider? provider = null) public static async Task<bool> IsExtensionValidWithNotifyAsync(UseCase useCae, string filePath, bool validateMediaFileTypes = true, Settings.Provider? provider = null)
{ {
var ext = Path.GetExtension(filePath).TrimStart('.').ToLowerInvariant(); if (FileTypes.IsAllowedPath(filePath, FileTypes.EXECUTABLES))
if(FileTypes.EXECUTABLES.FlattenExtensions().Contains(ext))
{ {
await MessageBus.INSTANCE.SendError(new( await MessageBus.INSTANCE.SendError(new(
Icons.Material.Filled.AppBlocking, Icons.Material.Filled.AppBlocking,
@ -53,7 +52,7 @@ public static class FileExtensionValidation
} }
var capabilities = provider?.GetModelCapabilities() ?? new(); var capabilities = provider?.GetModelCapabilities() ?? new();
if (FileTypes.IMAGE.FlattenExtensions().Contains(ext)) if (FileTypes.IsAllowedPath(filePath, FileTypes.IMAGE))
{ {
switch (useCae) switch (useCae)
{ {
@ -88,7 +87,7 @@ public static class FileExtensionValidation
} }
} }
if(FileTypes.VIDEO.FlattenExtensions().Contains(ext)) if (FileTypes.IsAllowedPath(filePath, FileTypes.VIDEO))
{ {
await MessageBus.INSTANCE.SendWarning(new( await MessageBus.INSTANCE.SendWarning(new(
Icons.Material.Filled.FeaturedVideo, Icons.Material.Filled.FeaturedVideo,
@ -96,7 +95,7 @@ public static class FileExtensionValidation
return false; return false;
} }
if(FileTypes.AUDIO.FlattenExtensions().Contains(ext)) if (FileTypes.IsAllowedPath(filePath, FileTypes.AUDIO))
{ {
await MessageBus.INSTANCE.SendWarning(new( await MessageBus.INSTANCE.SendWarning(new(
Icons.Material.Filled.AudioFile, Icons.Material.Filled.AudioFile,
@ -123,7 +122,7 @@ public static class FileExtensionValidation
return false; return false;
} }
if (FileTypes.IMAGE.FlattenExtensions().Any(x => x.Equals(ext, StringComparison.OrdinalIgnoreCase))) if (FileTypes.IsAllowedPath(filePath, FileTypes.IMAGE))
{ {
await MessageBus.INSTANCE.SendError(new( await MessageBus.INSTANCE.SendError(new(
Icons.Material.Filled.ImageNotSupported, Icons.Material.Filled.ImageNotSupported,