commit a1a682916ea63f6e1bcceaf0215c366aba50eb67 Author: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue Feb 4 14:39:54 2025 -0600 chore: initial commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..461a672 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,383 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 2 +tab_width = 2 + +# New line preferences +insert_final_newline = false + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# diagnostics +dotnet_diagnostic.CA1707.severity = none +dotnet_diagnostic.IDE0100.severity = none + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = omit_if_default:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:silent +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = true:silent +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:silent +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +dotnet_diagnostic.IDE0058.severity = none + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fdb040a --- /dev/null +++ b/.gitignore @@ -0,0 +1,488 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from `dotnet new gitignore` + +# appsettings +appsettings*.json +!appsettings.Example.json + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea/ + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba5d5d4 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# AltGen + +⚠️ **This project is still in development.** ⚠️ + +An LLM-powered alternative text generation tool for images. + diff --git a/src/AltGen.API.Tests/AltGen.API.Tests.csproj b/src/AltGen.API.Tests/AltGen.API.Tests.csproj new file mode 100644 index 0000000..cd27444 --- /dev/null +++ b/src/AltGen.API.Tests/AltGen.API.Tests.csproj @@ -0,0 +1,40 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + Always + + + Always + + + + diff --git a/src/AltGen.API.Tests/EndToEnd/EndToEndTest.cs b/src/AltGen.API.Tests/EndToEnd/EndToEndTest.cs new file mode 100644 index 0000000..4f3b59c --- /dev/null +++ b/src/AltGen.API.Tests/EndToEnd/EndToEndTest.cs @@ -0,0 +1,7 @@ +namespace AltGen.API.Tests.EndToEnd; + +public class EndToEndTest(AppFactory factory, TestConfiguration config) : IClassFixture, IClassFixture +{ + protected HttpClient Client { get; } = factory.CreateClient(); + protected TestConfiguration Config { get; } = config; +} \ No newline at end of file diff --git a/src/AltGen.API.Tests/EndToEnd/GenerateEndpointTests.cs b/src/AltGen.API.Tests/EndToEnd/GenerateEndpointTests.cs new file mode 100644 index 0000000..20a93d4 --- /dev/null +++ b/src/AltGen.API.Tests/EndToEnd/GenerateEndpointTests.cs @@ -0,0 +1,31 @@ + + +namespace AltGen.API.Tests.EndToEnd; + +public class GenerateEndpointTests( + AppFactory factory, + TestConfiguration config +) : EndToEndTest(factory, config) +{ + [Fact] + public async Task GenerateEndpoint_WhenCalled_ItShouldReturnOk() + { + var fileName = "library.jpg"; + var file = await TestFileManager.GetFileAsync(fileName); + var byteContent = new ByteArrayContent(file); + byteContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpg"); + + var content = new MultipartFormDataContent() + { + { new StringContent("Gemini"), "Provider" }, + { new StringContent(Config.GeminiApiKey), "ProviderKey" }, + { byteContent, "File", fileName } + }; + + var response = await Client.PostAsync("/generate", content); + var altText = await response.Content.ReadFromJsonAsync(); + + response.Should().HaveStatusCode(HttpStatusCode.OK); + altText!.AltText.Should().NotBeNullOrWhiteSpace(); + } +} \ No newline at end of file diff --git a/src/AltGen.API.Tests/EndToEnd/PromptEvaluation.cs b/src/AltGen.API.Tests/EndToEnd/PromptEvaluation.cs new file mode 100644 index 0000000..f9ecfcd --- /dev/null +++ b/src/AltGen.API.Tests/EndToEnd/PromptEvaluation.cs @@ -0,0 +1,49 @@ +namespace AltGen.API.Tests.EndToEnd; + +public class PromptEvaluations( + AppFactory factory, + TestConfiguration config +) : EndToEndTest(factory, config) +{ + // TODO: Use LLM-assisted prompt evaluation instead of + // doing partial string matching. Better suited due to + // subjective nature of the task. + [Theory] + [ClassData(typeof(TestData))] + public async Task Generate_WhenCalled_ItShouldRespondWithAltTextContainingKeyWords(string imageName, string[] keyWords) + { + var file = await TestFileManager.GetFileAsync(imageName); + var byteContent = new ByteArrayContent(file); + byteContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpg"); + + var content = new MultipartFormDataContent() + { + { new StringContent("Gemini"), "Provider" }, + { new StringContent(Config.GeminiApiKey), "ProviderKey" }, + { byteContent, "File", imageName } + }; + + var response = await Client.PostAsync("/generate", content); + var altText = await response.Content.ReadFromJsonAsync(); + + response.Should().HaveStatusCode(HttpStatusCode.OK); + altText!.AltText.ToLowerInvariant().Should().ContainAll(keyWords); + } + + class TestData : IEnumerable + { + public IEnumerator GetEnumerator() + { + yield return new object[] { "library.jpg", new[] { "book", } }; + yield return new object[] { "ascii_art.jpg", new[] { "test", "100", "success", } }; + yield return new object[] { "living_room.jpg", new[] { "blue", "dog", } }; + yield return new object[] { "podcast.jpg", new[] { "podcast", "scott", "mark", } }; + yield return new object[] { "youtube_comment.jpg", new[] { "feedback", } }; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} \ No newline at end of file diff --git a/src/AltGen.API.Tests/Files/ascii_art.jpg b/src/AltGen.API.Tests/Files/ascii_art.jpg new file mode 100644 index 0000000..6eb638f Binary files /dev/null and b/src/AltGen.API.Tests/Files/ascii_art.jpg differ diff --git a/src/AltGen.API.Tests/Files/library.jpg b/src/AltGen.API.Tests/Files/library.jpg new file mode 100644 index 0000000..e98fa7a Binary files /dev/null and b/src/AltGen.API.Tests/Files/library.jpg differ diff --git a/src/AltGen.API.Tests/Files/living_room.jpg b/src/AltGen.API.Tests/Files/living_room.jpg new file mode 100644 index 0000000..cef79e5 Binary files /dev/null and b/src/AltGen.API.Tests/Files/living_room.jpg differ diff --git a/src/AltGen.API.Tests/Files/podcast.jpg b/src/AltGen.API.Tests/Files/podcast.jpg new file mode 100644 index 0000000..799cb77 Binary files /dev/null and b/src/AltGen.API.Tests/Files/podcast.jpg differ diff --git a/src/AltGen.API.Tests/Files/youtube_comment.jpg b/src/AltGen.API.Tests/Files/youtube_comment.jpg new file mode 100644 index 0000000..abdc8bf Binary files /dev/null and b/src/AltGen.API.Tests/Files/youtube_comment.jpg differ diff --git a/src/AltGen.API.Tests/Fixtures/AppFactory.cs b/src/AltGen.API.Tests/Fixtures/AppFactory.cs new file mode 100644 index 0000000..9924774 --- /dev/null +++ b/src/AltGen.API.Tests/Fixtures/AppFactory.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Logging; + +namespace AltGen.API.Tests.Fixtures; + +public class AppFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureLogging(static l => l.ClearProviders()); + } +} \ No newline at end of file diff --git a/src/AltGen.API.Tests/Fixtures/TestConfiguration.cs b/src/AltGen.API.Tests/Fixtures/TestConfiguration.cs new file mode 100644 index 0000000..a09be22 --- /dev/null +++ b/src/AltGen.API.Tests/Fixtures/TestConfiguration.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Configuration; + +namespace AltGen.API.Tests.Fixtures; + +public class TestConfiguration +{ + static IConfiguration Config { get; } = new ConfigurationBuilder() + .AddJsonFile("appsettings.Test.json") + .AddEnvironmentVariables() + .Build(); + +#pragma warning disable CA1822 + public string GeminiApiKey => GetGeminiApiKey(); +#pragma warning restore CA1822 + + static string GetGeminiApiKey() + { + var apiKey = Config.GetSection("Gemini").GetValue("ApiKey"); + + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new InvalidOperationException("Gemini__ApiKey is required"); + } + + return apiKey; + } +} \ No newline at end of file diff --git a/src/AltGen.API.Tests/Integration/GenerateEndpointTests.cs b/src/AltGen.API.Tests/Integration/GenerateEndpointTests.cs new file mode 100644 index 0000000..e65aac4 --- /dev/null +++ b/src/AltGen.API.Tests/Integration/GenerateEndpointTests.cs @@ -0,0 +1,211 @@ +namespace AltGen.API.Tests.Integration; + +public class GenerateEndpointTests : IntegrationTest +{ + const string TestImageName = "library.jpg"; + + public GenerateEndpointTests(AppFactory factory) : base(factory) + { + MockGeminiServiceHandler.Clear(); + } + + [Theory] + [ClassData(typeof(InvalidRequestTestData))] + public async Task GenerateEndpoint_WhenCalledWithoutInvalidParameters_ItShouldReturnAProblemDetailWithStatusCode400(MultipartFormDataContent content, Dictionary errors) + { + var response = await Client.PostAsync("/generate", content); + var problem = await response.Content.ReadFromJsonAsync(); + + response.Should().HaveStatusCode(HttpStatusCode.BadRequest); + problem!.Errors.Should().BeEquivalentTo(errors); + } + + [Theory] + [ClassData(typeof(ValidRequestTestData))] + public async Task GenerateEndpoint_WhenCalledWithRequiredParameters_ItShouldReturnOkWithAltText(MultipartFormDataContent content) + { + MockGeminiServiceHandler + .When(HttpMethod.Post, "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent/?key=ProviderKey") + .Respond( + "application/json", + /*lang=json,strict*/ + @"{ + ""candidates"": [ + { + ""content"": { + ""parts"": [ + { ""text"": ""A long, perspective view of a classic library interior showcases richly colored wooden bookshelves filled with antique books, creating an atmosphere of history and scholarship.\n"" + } + ], + ""role"": ""model"" + }, + ""finishReason"": ""STOP"", + ""avgLogprobs"": -0.3317602475484212 + } + ], + ""usageMetadata"": { + ""promptTokenCount"": 405, + ""candidatesTokenCount"": 30, + ""totalTokenCount"": 435, + ""promptTokensDetails"": [ + { + ""modality"": ""TEXT"", + ""tokenCount"": 147 + }, + { + ""modality"": ""IMAGE"", + ""tokenCount"": 258 + } + ], + ""candidatesTokensDetails"": [ + { + ""modality"": ""TEXT"", + ""tokenCount"": 30 + } + ] + }, + ""modelVersion"": ""gemini-1.5-flash"" + }" + ); + + var response = await Client.PostAsync("/generate", content); + var altText = await response.Content.ReadFromJsonAsync(); + + response.Should().HaveStatusCode(HttpStatusCode.OK); + altText!.AltText.Should().NotBeNullOrWhiteSpace(); + } + + class ValidRequestTestData : IEnumerable + { + public IEnumerator GetEnumerator() + { + var testImage = TestFileManager.GetFile(TestImageName); + var byteContent = new ByteArrayContent(testImage); + byteContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpg"); + + yield return new object[] + { + new MultipartFormDataContent() + { + { new StringContent("Gemini"), "Provider" }, + { new StringContent("ProviderKey"), "ProviderKey" }, + { byteContent, "File", TestImageName } + } + }; + + yield return new object[] + { + new MultipartFormDataContent() + { + { new StringContent("Gemini"), "Provider" }, + { new StringContent("ProviderKey"), "ProviderKey" }, + { byteContent, "File", TestImageName } + } + }; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } + + class InvalidRequestTestData : IEnumerable + { + public IEnumerator GetEnumerator() + { + yield return new object[] + { + new MultipartFormDataContent() + { + { new StringContent(string.Empty), "Provider" }, + { new StringContent(string.Empty), "ProviderKey" }, + { new ByteArrayContent([]), "File", "file.jpeg" } + }, + new Dictionary + { + { "Provider", ["The Provider field is required."] }, + { "ProviderKey", ["The ProviderKey field is required."] }, + { "File", ["The File has no content."] } + }, + }; + + yield return new object[] + { + new MultipartFormDataContent() + { + { new StringContent("Gemini"), "Provider" }, + { new StringContent(string.Empty), "ProviderKey" }, + { new ByteArrayContent([]), "File", "file.jpeg" } + }, + new Dictionary + { + { "ProviderKey", ["The ProviderKey field is required."] }, + { "File", ["The File has no content."] } + }, + }; + + yield return new object[] + { + new MultipartFormDataContent() + { + { new StringContent(string.Empty), "Provider" }, + { new StringContent("ProviderKey"), "ProviderKey" }, + { new ByteArrayContent([]), "File", "file.jpeg" } + }, + new Dictionary + { + { "Provider", ["The Provider field is required."] }, + { "File", ["The File has no content."] } + }, + }; + + yield return new object[] + { + new MultipartFormDataContent() + { + { new StringContent("Gemini"), "Provider" }, + { new StringContent("ProviderKey"), "ProviderKey" }, + { new ByteArrayContent([]), "File", "file.jpeg" } + }, + new Dictionary + { + { "File", ["The File has no content."] } + }, + }; + + yield return new object[] + { + new MultipartFormDataContent() + { + { new StringContent("MadeUp"), "Provider" }, + { new StringContent("ProviderKey"), "ProviderKey" }, + { new ByteArrayContent(Encoding.UTF8.GetBytes("Hello, World!")), "File", "file.jpeg" } + }, + new Dictionary() + { + { "Provider", ["The Provider field is invalid."] } + }, + }; + + yield return new object[] + { + new MultipartFormDataContent() + { + { new StringContent("Gemini"), "Provider" }, + { new StringContent("ProviderKey"), "ProviderKey" }, + { new ByteArrayContent(Encoding.UTF8.GetBytes("Hello, World!")), "File", "file.txt" } + }, + new Dictionary() + { + { "File", ["The File must be a valid image file."] } + }, + }; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} \ No newline at end of file diff --git a/src/AltGen.API.Tests/Integration/IntegrationTest.cs b/src/AltGen.API.Tests/Integration/IntegrationTest.cs new file mode 100644 index 0000000..73ef108 --- /dev/null +++ b/src/AltGen.API.Tests/Integration/IntegrationTest.cs @@ -0,0 +1,20 @@ +namespace AltGen.API.Tests.Integration; + +public class IntegrationTest : IClassFixture +{ + protected MockHttpMessageHandler MockGeminiServiceHandler { get; } = new(); + protected WebApplicationFactory Factory { get; } + protected HttpClient Client { get; } + + public IntegrationTest(AppFactory factory) + { + Factory = factory.WithWebHostBuilder( + builder => builder.ConfigureTestServices( + services => services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => MockGeminiServiceHandler) + ) + ); + + Client = Factory.CreateClient(); + } +} \ No newline at end of file diff --git a/src/AltGen.API.Tests/Usings.cs b/src/AltGen.API.Tests/Usings.cs new file mode 100644 index 0000000..9e0919e --- /dev/null +++ b/src/AltGen.API.Tests/Usings.cs @@ -0,0 +1,18 @@ +global using System.Collections; +global using System.Net; +global using System.Net.Http.Headers; +global using System.Net.Http.Json; +global using System.Text; + +global using AltGen.API.Generate; +global using AltGen.API.Generate.Providers.Gemini; +global using AltGen.API.Tests.Fixtures; +global using AltGen.API.Tests.Utils; + +global using Microsoft.AspNetCore.Hosting; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.Mvc.Testing; +global using Microsoft.AspNetCore.TestHost; +global using Microsoft.Extensions.DependencyInjection; + +global using RichardSzalay.MockHttp; \ No newline at end of file diff --git a/src/AltGen.API.Tests/Utils/TestFileManager.cs b/src/AltGen.API.Tests/Utils/TestFileManager.cs new file mode 100644 index 0000000..d3c625e --- /dev/null +++ b/src/AltGen.API.Tests/Utils/TestFileManager.cs @@ -0,0 +1,18 @@ +namespace AltGen.API.Tests.Utils; + +public static class TestFileManager +{ + public static byte[] GetFile(string testFileName) + { + var filePath = Path.Combine(AppContext.BaseDirectory, "Files", testFileName); + var file = File.ReadAllBytes(filePath); + return file; + } + + public static Task GetFileAsync(string testFileName) + { + var filePath = Path.Combine(AppContext.BaseDirectory, "Files", testFileName); + var file = File.ReadAllBytesAsync(filePath); + return file; + } +} \ No newline at end of file diff --git a/src/AltGen.API.Tests/appsettings.Example.json b/src/AltGen.API.Tests/appsettings.Example.json new file mode 100644 index 0000000..fad8741 --- /dev/null +++ b/src/AltGen.API.Tests/appsettings.Example.json @@ -0,0 +1,5 @@ +{ + "Gemini": { + "ApiKey": "ApiKey" + } +} diff --git a/src/AltGen.API/AltGen.API.csproj b/src/AltGen.API/AltGen.API.csproj new file mode 100644 index 0000000..d73d24c --- /dev/null +++ b/src/AltGen.API/AltGen.API.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + + diff --git a/src/AltGen.API/AltGen.API.http b/src/AltGen.API/AltGen.API.http new file mode 100644 index 0000000..2c09d52 --- /dev/null +++ b/src/AltGen.API/AltGen.API.http @@ -0,0 +1,6 @@ +@AltGen.API_HostAddress = http://localhost:5005 + +GET {{AltGen.API_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/AltGen.API/Common/AltGenException.cs b/src/AltGen.API/Common/AltGenException.cs new file mode 100644 index 0000000..545c17c --- /dev/null +++ b/src/AltGen.API/Common/AltGenException.cs @@ -0,0 +1,5 @@ +namespace AltGen.API.Common; + +class AltGenException(string message) : Exception(message) +{ +} diff --git a/src/AltGen.API/Common/ValidationResultsExtensions.cs b/src/AltGen.API/Common/ValidationResultsExtensions.cs new file mode 100644 index 0000000..cf5e8c1 --- /dev/null +++ b/src/AltGen.API/Common/ValidationResultsExtensions.cs @@ -0,0 +1,14 @@ +namespace AltGen.API.Common; + +static class ValidationResultsExtensions +{ + public static Dictionary ToErrors(this IEnumerable results) + { + // group by the first member name and select the error message + return results.GroupBy(static r => r.MemberNames.First()) + .ToDictionary( + static r => r.Key, + static r => r.Select(static e => e.ErrorMessage!).ToArray() + ); + } +} diff --git a/src/AltGen.API/Generate/GenerateEndpoint.cs b/src/AltGen.API/Generate/GenerateEndpoint.cs new file mode 100644 index 0000000..1262f3f --- /dev/null +++ b/src/AltGen.API/Generate/GenerateEndpoint.cs @@ -0,0 +1,27 @@ +namespace AltGen.API.Generate; + +static class GenerateEndpoint +{ + public static async Task HandleAsync([AsParameters] GenerateRequest request, [FromServices] IAltTextProviderFactory factory) + { + var validationResults = request.Validate(new ValidationContext(request)); + + if (validationResults.Any()) + { + return Results.ValidationProblem(validationResults.ToErrors()); + } + + var provider = factory.Create(request.Provider); + + var imageStream = new MemoryStream(); + await request.File.CopyToAsync(imageStream); + + var altText = await provider.GenerateAltTextAsync( + request.ProviderKey, + request.File.ContentType, + imageStream + ); + + return Results.Ok(new GenerateResponse(altText)); + } +} \ No newline at end of file diff --git a/src/AltGen.API/Generate/GenerateRequest.cs b/src/AltGen.API/Generate/GenerateRequest.cs new file mode 100644 index 0000000..bcc28ff --- /dev/null +++ b/src/AltGen.API/Generate/GenerateRequest.cs @@ -0,0 +1,47 @@ +namespace AltGen.API.Generate; + +record GenerateRequest( + [FromForm] + string Provider, + [FromForm] + string ProviderKey, + IFormFile File +) : IValidatableObject +{ + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(Provider)) + { + yield return new ValidationResult($"The {nameof(Provider)} field is required.", [nameof(Provider)]); + } + + if (string.IsNullOrWhiteSpace(Provider) is false && LLMProvider.IsValid(Provider) is false) + { + yield return new ValidationResult($"The {nameof(Provider)} field is invalid.", [nameof(Provider)]); + } + + if (string.IsNullOrWhiteSpace(ProviderKey)) + { + yield return new ValidationResult($"The {nameof(ProviderKey)} field is required.", [nameof(ProviderKey)]); + } + + // TODO: Probably need to consider upper bound for file size + // this also needs to take into account provider-specific limits + if (File.Length is 0) + { + yield return new ValidationResult($"The {nameof(File)} has no content.", [nameof(File)]); + } + + if (File.Length > 0) + { + var extension = Path.GetExtension(File.FileName); + + // TODO: prob needs to be specific to the provider + // different LLMs support different image formats + if (extension is not ".jpeg" and not ".jpg" and not ".png") + { + yield return new ValidationResult($"The {nameof(File)} must be a valid image file.", [nameof(File)]); + } + } + } +} diff --git a/src/AltGen.API/Generate/GenerateResponse.cs b/src/AltGen.API/Generate/GenerateResponse.cs new file mode 100644 index 0000000..b6a9997 --- /dev/null +++ b/src/AltGen.API/Generate/GenerateResponse.cs @@ -0,0 +1,3 @@ +namespace AltGen.API.Generate; + +record GenerateResponse(string AltText); diff --git a/src/AltGen.API/Generate/Providers/AltTextProviderFactory.cs b/src/AltGen.API/Generate/Providers/AltTextProviderFactory.cs new file mode 100644 index 0000000..caaba7f --- /dev/null +++ b/src/AltGen.API/Generate/Providers/AltTextProviderFactory.cs @@ -0,0 +1,15 @@ +namespace AltGen.API.Generate.Providers; + +class AltTextProviderFactory(IServiceProvider serviceProvider) : IAltTextProviderFactory +{ + readonly IServiceProvider _serviceProvider = serviceProvider; + + public IAltTextProvider Create(string provider) + { + return provider switch + { + LLMProvider.Gemini => _serviceProvider.GetRequiredKeyedService(LLMProvider.Gemini), + _ => throw new NotSupportedException($"The provider '{provider}' is not supported.") + }; + } +} \ No newline at end of file diff --git a/src/AltGen.API/Generate/Providers/Gemini/Candidate.cs b/src/AltGen.API/Generate/Providers/Gemini/Candidate.cs new file mode 100644 index 0000000..61644c1 --- /dev/null +++ b/src/AltGen.API/Generate/Providers/Gemini/Candidate.cs @@ -0,0 +1,3 @@ +namespace AltGen.API.Generate.Providers.Gemini; + +record Candidate(Content Content); diff --git a/src/AltGen.API/Generate/Providers/Gemini/Content.cs b/src/AltGen.API/Generate/Providers/Gemini/Content.cs new file mode 100644 index 0000000..62a2147 --- /dev/null +++ b/src/AltGen.API/Generate/Providers/Gemini/Content.cs @@ -0,0 +1,3 @@ +namespace AltGen.API.Generate.Providers.Gemini; + +record Content(Part[] Parts, string Role); diff --git a/src/AltGen.API/Generate/Providers/Gemini/GeminiAltTextProvider.cs b/src/AltGen.API/Generate/Providers/Gemini/GeminiAltTextProvider.cs new file mode 100644 index 0000000..33e2781 --- /dev/null +++ b/src/AltGen.API/Generate/Providers/Gemini/GeminiAltTextProvider.cs @@ -0,0 +1,11 @@ +namespace AltGen.API.Generate.Providers.Gemini; + +class GeminiAltTextProvider(IGeminiService geminiService) : IAltTextProvider +{ + readonly IGeminiService _geminiService = geminiService; + + public Task GenerateAltTextAsync(string providerKey, string mimeType, MemoryStream image) + { + return _geminiService.GenerateContentAsync(providerKey, mimeType, image); + } +} diff --git a/src/AltGen.API/Generate/Providers/Gemini/GeminiRequest.cs b/src/AltGen.API/Generate/Providers/Gemini/GeminiRequest.cs new file mode 100644 index 0000000..6f870f0 --- /dev/null +++ b/src/AltGen.API/Generate/Providers/Gemini/GeminiRequest.cs @@ -0,0 +1,6 @@ +namespace AltGen.API.Generate.Providers.Gemini; + +record GeminiRequest( + Content SystemInstruction, + Content[] Contents +); diff --git a/src/AltGen.API/Generate/Providers/Gemini/GeminiResponse.cs b/src/AltGen.API/Generate/Providers/Gemini/GeminiResponse.cs new file mode 100644 index 0000000..541a4e5 --- /dev/null +++ b/src/AltGen.API/Generate/Providers/Gemini/GeminiResponse.cs @@ -0,0 +1,3 @@ +namespace AltGen.API.Generate.Providers.Gemini; + +record GeminiResponse(Candidate[] Candidates); diff --git a/src/AltGen.API/Generate/Providers/Gemini/GeminiRole.cs b/src/AltGen.API/Generate/Providers/Gemini/GeminiRole.cs new file mode 100644 index 0000000..0ac85cc --- /dev/null +++ b/src/AltGen.API/Generate/Providers/Gemini/GeminiRole.cs @@ -0,0 +1,8 @@ +namespace AltGen.API.Generate.Providers.Gemini; + +static class GeminiRole +{ + public const string System = "system"; + public const string User = "user"; + public const string Model = "model"; +} \ No newline at end of file diff --git a/src/AltGen.API/Generate/Providers/Gemini/GeminiService.cs b/src/AltGen.API/Generate/Providers/Gemini/GeminiService.cs new file mode 100644 index 0000000..4d3caae --- /dev/null +++ b/src/AltGen.API/Generate/Providers/Gemini/GeminiService.cs @@ -0,0 +1,79 @@ +namespace AltGen.API.Generate.Providers.Gemini; + +class GeminiService(HttpClient httpClient) : IGeminiService +{ + const string BaseUri = "https://generativelanguage.googleapis.com/v1beta/models"; + const string Method = "generateContent"; + const string ModelId = "gemini-1.5-flash"; + const string ApiKeyQueryKey = "key"; + + // TODO: Use Lazy to load the prompt resource + string _prompt = ""; + string Prompt + { + get + { + if (string.IsNullOrWhiteSpace(_prompt)) + { + var resource = Assembly.GetExecutingAssembly() + .GetManifestResourceStream("AltGen.API.Resources.prompt.txt") ?? throw new AltGenException("Failed to load the prompt resource."); + using var reader = new StreamReader(resource); + _prompt = reader.ReadToEnd(); + } + + return _prompt; + } + } + + static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new PartConverter() } + }; + readonly HttpClient _httpClient = httpClient; + + public async Task GenerateContentAsync(string providerKey, string mimeType, MemoryStream image) + { + var requestUri = GetRequestUri(providerKey); + var imageBase64 = ConvertToBase64(image); + + var request = new GeminiRequest( + new Content([new TextPart(Prompt)], GeminiRole.System), + [new Content( + [ + new TextPart(""), + new InlineDataPart(new InlineData(mimeType, imageBase64)) + ], + GeminiRole.User + )] + ); + + var response = await _httpClient.PostAsJsonAsync(requestUri, request, Options); + var content = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode is false) + { + throw new AltGenException("Failed to generate alt text."); + } + + var geminiResponse = JsonSerializer.Deserialize(content, Options) ?? throw new AltGenException("Failed to deserialize the Gemini response."); + var firstCandidate = geminiResponse.Candidates.FirstOrDefault() ?? throw new AltGenException("No candidates found in the Gemini response."); + var altText = firstCandidate.Content.Parts + .OfType() + .Aggregate(new StringBuilder(), static (sb, part) => sb.Append(part.Text)) + .ToString(); + + return altText; + } + + static string GetRequestUri(string providerKey) + { + return $"{BaseUri}/{ModelId}:{Method}/?{ApiKeyQueryKey}={providerKey}"; + } + + static string ConvertToBase64(MemoryStream image) + { + return Convert.ToBase64String(image.ToArray()); + } +} \ No newline at end of file diff --git a/src/AltGen.API/Generate/Providers/Gemini/IGeminiService.cs b/src/AltGen.API/Generate/Providers/Gemini/IGeminiService.cs new file mode 100644 index 0000000..e62c4dd --- /dev/null +++ b/src/AltGen.API/Generate/Providers/Gemini/IGeminiService.cs @@ -0,0 +1,6 @@ +namespace AltGen.API.Generate.Providers.Gemini; + +interface IGeminiService +{ + Task GenerateContentAsync(string providerKey, string mimeType, MemoryStream image); +} diff --git a/src/AltGen.API/Generate/Providers/Gemini/Part.cs b/src/AltGen.API/Generate/Providers/Gemini/Part.cs new file mode 100644 index 0000000..b617c80 --- /dev/null +++ b/src/AltGen.API/Generate/Providers/Gemini/Part.cs @@ -0,0 +1,9 @@ +namespace AltGen.API.Generate.Providers.Gemini; + +record Part(); + +record TextPart(string Text) : Part; + +record InlineDataPart(InlineData InlineData) : Part; + +record InlineData(string MimeType, string Data); \ No newline at end of file diff --git a/src/AltGen.API/Generate/Providers/Gemini/PartConverter.cs b/src/AltGen.API/Generate/Providers/Gemini/PartConverter.cs new file mode 100644 index 0000000..e783dce --- /dev/null +++ b/src/AltGen.API/Generate/Providers/Gemini/PartConverter.cs @@ -0,0 +1,27 @@ +namespace AltGen.API.Generate.Providers.Gemini; + +class PartConverter : JsonConverter +{ + public override Part? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + if (root.TryGetProperty("text", out var text)) + { + return JsonSerializer.Deserialize(root.GetRawText(), options); + } + + if (root.TryGetProperty("inlineData", out var inlineData)) + { + return JsonSerializer.Deserialize(root.GetRawText(), options); + } + + throw new JsonException("Invalid part type."); + } + + public override void Write(Utf8JsonWriter writer, Part value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } +} \ No newline at end of file diff --git a/src/AltGen.API/Generate/Providers/IAltTextProvider.cs b/src/AltGen.API/Generate/Providers/IAltTextProvider.cs new file mode 100644 index 0000000..479db90 --- /dev/null +++ b/src/AltGen.API/Generate/Providers/IAltTextProvider.cs @@ -0,0 +1,6 @@ +namespace AltGen.API.Generate.Providers; + +interface IAltTextProvider +{ + Task GenerateAltTextAsync(string providerKey, string mimeType, MemoryStream image); +} \ No newline at end of file diff --git a/src/AltGen.API/Generate/Providers/IAltTextProviderFactory.cs b/src/AltGen.API/Generate/Providers/IAltTextProviderFactory.cs new file mode 100644 index 0000000..acbb34d --- /dev/null +++ b/src/AltGen.API/Generate/Providers/IAltTextProviderFactory.cs @@ -0,0 +1,6 @@ +namespace AltGen.API.Generate.Providers; + +interface IAltTextProviderFactory +{ + IAltTextProvider Create(string provider); +} \ No newline at end of file diff --git a/src/AltGen.API/Generate/Providers/LLMProvider.cs b/src/AltGen.API/Generate/Providers/LLMProvider.cs new file mode 100644 index 0000000..7f01788 --- /dev/null +++ b/src/AltGen.API/Generate/Providers/LLMProvider.cs @@ -0,0 +1,11 @@ +namespace AltGen.API.Generate.Providers; + +static class LLMProvider +{ + public const string Gemini = "Gemini"; + + public static bool IsValid(string provider) + { + return provider is Gemini; + } +} diff --git a/src/AltGen.API/Program.cs b/src/AltGen.API/Program.cs new file mode 100644 index 0000000..db51204 --- /dev/null +++ b/src/AltGen.API/Program.cs @@ -0,0 +1,26 @@ +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddProblemDetails(); +builder.Services.AddOpenApi(); + +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +builder.Services.AddKeyedSingleton(LLMProvider.Gemini); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); + +app.UseStatusCodePages(); + +app.MapPost("/generate", GenerateEndpoint.HandleAsync).DisableAntiforgery(); + +app.Run(); + +[ExcludeFromCodeCoverage] +public partial class Program { } \ No newline at end of file diff --git a/src/AltGen.API/Properties/launchSettings.json b/src/AltGen.API/Properties/launchSettings.json new file mode 100644 index 0000000..3978c3e --- /dev/null +++ b/src/AltGen.API/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7297;http://localhost:5005", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5005", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + } +} diff --git a/src/AltGen.API/Resources/prompt.txt b/src/AltGen.API/Resources/prompt.txt new file mode 100644 index 0000000..8a33abe --- /dev/null +++ b/src/AltGen.API/Resources/prompt.txt @@ -0,0 +1,17 @@ +Generate descriptive alt text that captures the essential visual information of the image. Follow these guidelines: + +1. Be concise, but comprehensive (1-2 sentences) +2. Describe the most important visual elements +3. Convey the image's purpose or key message +4. Use objective language +5. Avoid redundant phrases + +Priority details to include: +- Main subject(s) +- Action or context +- Color or distinctive visual characteristics +- Emotional tone or artistic intent + +Exclude unnecessary details like background minutiae or decorative elements unless they are crucial to understanding the image. + +Please DO NOT respond with anything over than the alt text. diff --git a/src/AltGen.API/Usings.cs b/src/AltGen.API/Usings.cs new file mode 100644 index 0000000..8ad16ae --- /dev/null +++ b/src/AltGen.API/Usings.cs @@ -0,0 +1,13 @@ +global using System.ComponentModel.DataAnnotations; +global using System.Diagnostics.CodeAnalysis; +global using System.Reflection; +global using System.Text; +global using System.Text.Json; +global using System.Text.Json.Serialization; + +global using AltGen.API.Common; +global using AltGen.API.Generate; +global using AltGen.API.Generate.Providers; +global using AltGen.API.Generate.Providers.Gemini; + +global using Microsoft.AspNetCore.Mvc; diff --git a/src/AltGen.sln b/src/AltGen.sln new file mode 100644 index 0000000..d9e7d5d --- /dev/null +++ b/src/AltGen.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltGen.API", "AltGen.API\AltGen.API.csproj", "{2865924B-D42C-4126-856F-AD0B762710FB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AltGen.API.Tests", "AltGen.API.Tests\AltGen.API.Tests.csproj", "{FC95942B-3579-4F83-A447-A96D8EEF8E45}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2865924B-D42C-4126-856F-AD0B762710FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2865924B-D42C-4126-856F-AD0B762710FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2865924B-D42C-4126-856F-AD0B762710FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2865924B-D42C-4126-856F-AD0B762710FB}.Release|Any CPU.Build.0 = Release|Any CPU + {FC95942B-3579-4F83-A447-A96D8EEF8E45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC95942B-3579-4F83-A447-A96D8EEF8E45}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC95942B-3579-4F83-A447-A96D8EEF8E45}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC95942B-3579-4F83-A447-A96D8EEF8E45}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal