mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 16:27:21 +00:00
Added PowerPoint templates to the Slide Assistant (#1055)
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
1 parent
8c5cfa78b6
commit
6eb23ebbcb
136 files changed
+16535
-522
No files matched your search
@@ -235,7 +235,13 @@ public static class ConfigurationSelectDataFactory
|
||||
foreach (var expertise in Enum.GetValues<AudienceExpertise>())
|
||||
yield return new(expertise.Name(), expertise);
|
||||
}
|
||||
|
||||
|
||||
public static IEnumerable<ConfigurationSelectData<PresentationOccasion>> GetSlideBuilderOccasionData()
|
||||
{
|
||||
foreach (var occasion in Enum.GetValues<PresentationOccasion>())
|
||||
yield return new(occasion.Name(), occasion);
|
||||
}
|
||||
|
||||
public static IEnumerable<ConfigurationSelectData<string>> GetProfilesData(IEnumerable<Profile> profiles)
|
||||
{
|
||||
foreach (var profile in profiles.GetAllProfiles())
|
||||
|
||||
@@ -204,7 +204,7 @@ public sealed class Data
|
||||
|
||||
public DataEMail EMail { get; init; } = new();
|
||||
|
||||
public DataSlideBuilder SlideBuilder { get; init; } = new();
|
||||
public DataSlideBuilder SlideBuilder { get; init; } = new(x => x.SlideBuilder);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the managed Assistant Builder defaults.
|
||||
|
||||
@@ -1,14 +1,101 @@
|
||||
using AIStudio.Assistants.SlideBuilder;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
using AIStudio.Assistants.SlideBuilder;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Tools.PowerPoint;
|
||||
|
||||
namespace AIStudio.Settings.DataModel;
|
||||
|
||||
public class DataSlideBuilder
|
||||
/// <summary>
|
||||
/// Stores the settings of the Slide Assistant.
|
||||
/// </summary>
|
||||
/// <param name="configSelection">The managed-configuration selector.</param>
|
||||
public class DataSlideBuilder(Expression<Func<Data, DataSlideBuilder>>? configSelection = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes an unmanaged Slide Assistant settings instance.
|
||||
/// </summary>
|
||||
public DataSlideBuilder() : this(null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The PowerPoint presentation or template (.pptx, .potx) whose layouts the slides use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Empty for no template, which makes the assistant plan an outline only. Like the other
|
||||
/// preselections, the template applies only while preselection is enabled: an organization
|
||||
/// which wants its corporate template used for sure locks PreselectOptions as well.
|
||||
/// </remarks>
|
||||
public string TemplatePath { get; set; } = ManagedConfiguration.Register(configSelection, value => value.TemplatePath, string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Preselect the footer text of the slides?
|
||||
/// </summary>
|
||||
public string PreselectedFooterText { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedFooterText, string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// The most bullet points per slide the user can ask for.
|
||||
/// </summary>
|
||||
public const int MAX_BULLET_POINTS_PER_SLIDE = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Preselect the maximum number of bullet points per slide, all text slots of a slide together?
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 0 sets no maximum, as in the assistant, where the LLM decides by the room of each layout. The
|
||||
/// number should fit the room for text in the layouts of the template, which is why organizations
|
||||
/// may dictate it.
|
||||
/// </remarks>
|
||||
public int PreselectedMaxBulletPointsPerSlide { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedMaxBulletPointsPerSlide, 0);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a number of bullet points per slide can be preselected: 0 for no maximum, or up to MAX_BULLET_POINTS_PER_SLIDE.
|
||||
/// </summary>
|
||||
public static bool IsValidMaxBulletPointsPerSlide(int value) => value is >= 0 and <= MAX_BULLET_POINTS_PER_SLIDE;
|
||||
|
||||
/// <summary>
|
||||
/// Preselect keeping the picture placeholders which a new slide has no picture for?
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Off by default: the slide leaves them out, as every other placeholder without content. When
|
||||
/// on, PowerPoint shows them while editing, for pictures of the user's own.
|
||||
/// </remarks>
|
||||
public bool PreselectedKeepEmptyPicturePlaceholders { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedKeepEmptyPicturePlaceholders, false);
|
||||
|
||||
/// <summary>
|
||||
/// Preselect the folder of pictures and icons which the LLM may choose from by their names?
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Empty for no picture library. Like the template, the folder applies only while preselection
|
||||
/// is enabled, and a configuration plugin may ship it and name it relative to its directory.
|
||||
/// </remarks>
|
||||
public string PreselectedPictureLibraryPath { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedPictureLibraryPath, string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Preselect the layouts for kinds of slides, i.e., the name of a layout of the template by kind of slide?
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A kind of slide which is missing has no preference, and the LLM decides. Names instead of IDs
|
||||
/// keep the choices valid across sessions, computers, and versions of the template; a name which
|
||||
/// the current template lacks counts as no preference.
|
||||
/// </remarks>
|
||||
public Dictionary<LayoutPurpose, string> PreselectedLayouts { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedLayouts, new Dictionary<LayoutPurpose, string>());
|
||||
|
||||
/// <summary>
|
||||
/// Preselect what the presentation does with the slides of the preselected template, by the number of each slide?
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The slides count from 1, in the order of the file, hidden ones included. A slide which is
|
||||
/// missing is left out, as by default. The choices belong to the preselected template: for any
|
||||
/// other template, the assistant ignores them, since its slides are other ones.
|
||||
/// </remarks>
|
||||
public Dictionary<int, TemplateSlideUse> PreselectedTemplateSlideUses { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectedTemplateSlideUses, new Dictionary<int, TemplateSlideUse>());
|
||||
|
||||
/// <summary>
|
||||
/// Preselect any Slide Builder options?
|
||||
/// </summary>
|
||||
public bool PreselectOptions { get; set; } = true;
|
||||
public bool PreselectOptions { get; set; } = ManagedConfiguration.Register(configSelection, value => value.PreselectOptions, true);
|
||||
|
||||
/// <summary>
|
||||
/// Preselect a profile?
|
||||
@@ -49,7 +136,17 @@ public class DataSlideBuilder
|
||||
/// Preselect the audience expertise?
|
||||
/// </summary>
|
||||
public AudienceExpertise PreselectedAudienceExpertise { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Preselect the occasion of the presentation?
|
||||
/// </summary>
|
||||
public PresentationOccasion PreselectedOccasion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Preselect any other occasion?
|
||||
/// </summary>
|
||||
public string PreselectedOtherOccasion { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Preselect any important aspects that the Slide Builder should take into account?
|
||||
/// </summary>
|
||||
|
||||
@@ -153,6 +153,7 @@ public static partial class ManagedConfiguration
|
||||
/// <param name="configSelection">The expression to select the configuration class.</param>
|
||||
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
|
||||
/// <param name="dryRun">When true, the method will not apply any changes, but only check if the configuration can be read.</param>
|
||||
/// <param name="convert">Turns the configured text into the value to store, e.g., a path relative to the plugin into a full one; null rejects the text. Without it, the text is stored as it is.</param>
|
||||
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
|
||||
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
|
||||
public static bool TryProcessConfiguration<TClass>(
|
||||
@@ -160,9 +161,10 @@ public static partial class ManagedConfiguration
|
||||
Expression<Func<TClass, string>> propertyExpression,
|
||||
Guid configPluginId,
|
||||
LuaTable settings,
|
||||
bool dryRun)
|
||||
bool dryRun,
|
||||
Func<string, string?>? convert = null)
|
||||
{
|
||||
return TryProcessConfiguration(configSelection, propertyExpression, string.Empty, configPluginId, settings, dryRun);
|
||||
return TryProcessConfiguration(configSelection, propertyExpression, string.Empty, configPluginId, settings, dryRun, convert);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -179,6 +181,7 @@ public static partial class ManagedConfiguration
|
||||
/// <param name="configSelection">The expression to select the configuration class.</param>
|
||||
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
|
||||
/// <param name="dryRun">When true, the method will not apply any changes, but only check if the configuration can be read.</param>
|
||||
/// <param name="convert">For plain strings: turns the configured text into the value to store; null rejects the text. Without it, the text is stored as it is.</param>
|
||||
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
|
||||
/// <typeparam name="TDataType">The data type of the configured value.</typeparam>
|
||||
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
|
||||
@@ -188,7 +191,8 @@ public static partial class ManagedConfiguration
|
||||
TDataType configuredType,
|
||||
Guid configPluginId,
|
||||
LuaTable settings,
|
||||
bool dryRun)
|
||||
bool dryRun,
|
||||
Func<string, string?>? convert = null)
|
||||
{
|
||||
//
|
||||
// Handle configured string values
|
||||
@@ -217,8 +221,9 @@ public static partial class ManagedConfiguration
|
||||
|
||||
// Case: the read string is just a string:
|
||||
case string:
|
||||
configuredValue = configuredText;
|
||||
successful = true;
|
||||
var convertedText = convert is null ? configuredText : convert(configuredText);
|
||||
successful = convertedText is not null;
|
||||
configuredValue = convertedText ?? configuredText;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -901,6 +906,156 @@ public static partial class ManagedConfiguration
|
||||
return HandleParsedScalarValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to process the configuration settings from a Lua table for dictionaries which map enum keys to texts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the configuration is successfully processed, it updates the configuration metadata with the configured value.
|
||||
/// Furthermore, it applies the configured managed state to the provided configuration plugin ID.
|
||||
/// The setting's value is set to the configured value when locked or when the editable default should apply.
|
||||
/// A key which names no member of the enum is passed over, as with the enum dictionaries.
|
||||
/// </remarks>
|
||||
/// <param name="configPluginId">The ID of the related configuration plugin.</param>
|
||||
/// <param name="settings">The Lua table containing the settings to process.</param>
|
||||
/// <param name="configSelection">The expression to select the configuration class.</param>
|
||||
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
|
||||
/// <param name="dryRun">When true, the method will not apply any changes but only check if the configuration can be read.</param>
|
||||
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
|
||||
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
|
||||
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
|
||||
public static bool TryProcessConfiguration<TClass, TKey>(
|
||||
Expression<Func<Data, TClass>> configSelection,
|
||||
Expression<Func<TClass, Dictionary<TKey, string>>> propertyExpression,
|
||||
Guid configPluginId,
|
||||
LuaTable settings,
|
||||
bool dryRun)
|
||||
where TKey : struct, Enum
|
||||
{
|
||||
//
|
||||
// Handle configured dictionaries of enum keys and texts (Lua keys and values are strings)
|
||||
//
|
||||
|
||||
// Check if that configuration was registered:
|
||||
if(!TryGet(configSelection, propertyExpression, out var configMeta))
|
||||
return false;
|
||||
|
||||
var successful = false;
|
||||
var configuredValue = new Dictionary<TKey, string>(configMeta.Default);
|
||||
|
||||
// Step 1 -- try to read the Lua value (we expect a table) out of the Lua table:
|
||||
if (settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredLuaList) &&
|
||||
configuredLuaList.Type is LuaValueType.Table &&
|
||||
configuredLuaList.TryRead<LuaTable>(out var valueTable))
|
||||
{
|
||||
configuredValue.Clear();
|
||||
|
||||
// In order to iterate over all key-value pairs in the Lua table, we have to use TryGetNext.
|
||||
// Thus, we initialize the previous key variable to Nil and keep calling TryGetNext until
|
||||
// there are no more pairs:
|
||||
var previousKey = LuaValue.Nil;
|
||||
while(valueTable.TryGetNext(previousKey, out var pair))
|
||||
{
|
||||
// Update the previous key for the next iteration:
|
||||
previousKey = pair.Key;
|
||||
|
||||
// Try to read both the key and the value as strings:
|
||||
var hadKey = pair.Key.TryRead<string>(out var keyText);
|
||||
var hadValue = pair.Value.TryRead<string>(out var valueText);
|
||||
|
||||
// If both were read successfully and the key names a member of the enum, add them to the dictionary:
|
||||
if (hadKey && hadValue && EnumNames.TryParse<TKey>(keyText, out var key))
|
||||
configuredValue[key] = valueText;
|
||||
}
|
||||
|
||||
successful = true;
|
||||
}
|
||||
|
||||
if(dryRun)
|
||||
return successful;
|
||||
|
||||
var settingName = SettingName(propertyExpression);
|
||||
var managedMode = ReadManagedConfigurationMode(propertyExpression, settings);
|
||||
return HandleParsedScalarValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to process the configuration settings from a Lua table for dictionaries which map numbers to enum values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the configuration is successfully processed, it updates the configuration metadata with the configured value.
|
||||
/// Furthermore, it applies the configured managed state to the provided configuration plugin ID.
|
||||
/// The setting's value is set to the configured value when locked or when the editable default should apply.
|
||||
/// The numbers count from 1, like the slides of a presentation. An entry whose key is no such number or
|
||||
/// whose value names no member of the enum is passed over with a warning, since the plugin's author
|
||||
/// would otherwise never learn why the entry has no effect.
|
||||
/// </remarks>
|
||||
/// <param name="configPluginId">The ID of the related configuration plugin.</param>
|
||||
/// <param name="settings">The Lua table containing the settings to process.</param>
|
||||
/// <param name="configSelection">The expression to select the configuration class.</param>
|
||||
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
|
||||
/// <param name="dryRun">When true, the method will not apply any changes but only check if the configuration can be read.</param>
|
||||
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
|
||||
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
|
||||
/// <returns>True when the configuration was successfully processed, otherwise false.</returns>
|
||||
public static bool TryProcessConfiguration<TClass, TValue>(
|
||||
Expression<Func<Data, TClass>> configSelection,
|
||||
Expression<Func<TClass, Dictionary<int, TValue>>> propertyExpression,
|
||||
Guid configPluginId,
|
||||
LuaTable settings,
|
||||
bool dryRun)
|
||||
where TValue : struct, Enum
|
||||
{
|
||||
//
|
||||
// Handle configured dictionaries of numbers and enum values (Lua keys are numbers, values are strings)
|
||||
//
|
||||
|
||||
// Check if that configuration was registered:
|
||||
if(!TryGet(configSelection, propertyExpression, out var configMeta))
|
||||
return false;
|
||||
|
||||
var successful = false;
|
||||
var configuredValue = new Dictionary<int, TValue>(configMeta.Default);
|
||||
|
||||
// Step 1 -- try to read the Lua value (we expect a table) out of the Lua table:
|
||||
if (settings.TryGetValue(SettingsManager.ToSettingName(propertyExpression), out var configuredLuaList) &&
|
||||
configuredLuaList.Type is LuaValueType.Table &&
|
||||
configuredLuaList.TryRead<LuaTable>(out var valueTable))
|
||||
{
|
||||
configuredValue.Clear();
|
||||
|
||||
// In order to iterate over all key-value pairs in the Lua table, we have to use TryGetNext.
|
||||
// Thus, we initialize the previous key variable to Nil and keep calling TryGetNext until
|
||||
// there are no more pairs:
|
||||
var previousKey = LuaValue.Nil;
|
||||
while(valueTable.TryGetNext(previousKey, out var pair))
|
||||
{
|
||||
// Update the previous key for the next iteration:
|
||||
previousKey = pair.Key;
|
||||
|
||||
// Try to read the key as a number and the value as a string:
|
||||
var hadKey = pair.Key.TryRead<int>(out var key) && pair.Key.Type is LuaValueType.Number && key > 0;
|
||||
var hadValue = pair.Value.TryRead<string>(out var valueText);
|
||||
|
||||
// If both were read successfully and the value names a member of the enum, add them to the dictionary:
|
||||
if (hadKey && hadValue && EnumNames.TryParse<TValue>(valueText, out var value))
|
||||
configuredValue[key] = value;
|
||||
|
||||
// A dry run comes before the actual one, so only the actual one reports the entry:
|
||||
else if (!dryRun)
|
||||
Log.LogWarning($"The configuration plugin '{configPluginId}' has the entry '{pair.Key}' = '{pair.Value}' in the setting '{configMeta.SettingName}', which is no valid entry. The keys are numbers from 1, and the values are names of options. Ignoring it.");
|
||||
}
|
||||
|
||||
successful = true;
|
||||
}
|
||||
|
||||
if(dryRun)
|
||||
return successful;
|
||||
|
||||
var settingName = SettingName(propertyExpression);
|
||||
var managedMode = ReadManagedConfigurationMode(propertyExpression, settings);
|
||||
return HandleParsedScalarValue(configPluginId, dryRun, successful, configMeta, configuredValue, managedMode, settingName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the parsed configuration value based on whether the parsing was successful and whether it's a dry run.
|
||||
/// </summary>
|
||||
|
||||
@@ -288,4 +288,76 @@ public static partial class ManagedConfiguration
|
||||
|
||||
return defaultValues;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a configuration setting with a default dictionary which maps enum keys to texts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the method is invoked with a null configSelection, the configuration path
|
||||
/// is ignored, and the specified default values are returned without registration.
|
||||
/// </remarks>
|
||||
/// <param name="configSelection">The expression that selects the configuration class from the root Data model.</param>
|
||||
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
|
||||
/// <param name="defaultValues">The default dictionary of values to use when the setting is not configured.</param>
|
||||
/// <typeparam name="TClass">The type of the configuration class from which the property is selected.</typeparam>
|
||||
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
|
||||
/// <returns>A dictionary containing the default values.</returns>
|
||||
public static Dictionary<TKey, string> Register<TClass, TKey>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, Dictionary<TKey, string>>> propertyExpression, Dictionary<TKey, string> defaultValues)
|
||||
where TKey : struct, Enum
|
||||
{
|
||||
// When called from the JSON deserializer by using the standard constructor,
|
||||
// we ignore the register call and return the default value:
|
||||
if (configSelection is null)
|
||||
return new();
|
||||
|
||||
var configPath = Path(configSelection, propertyExpression);
|
||||
|
||||
// If the metadata already exists for this configuration path, we return the default value:
|
||||
if (METADATA.ContainsKey(configPath))
|
||||
return defaultValues;
|
||||
|
||||
// Not registered yet, so we register it now:
|
||||
METADATA[configPath] = new ConfigMeta<TClass, Dictionary<TKey, string>>(configSelection, propertyExpression)
|
||||
{
|
||||
Default = defaultValues,
|
||||
};
|
||||
|
||||
return defaultValues;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a configuration setting with a default dictionary which maps numbers to enum values, e.g., slide numbers to a use.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When the method is invoked with a null configSelection, the configuration path
|
||||
/// is ignored, and the specified default values are returned without registration.
|
||||
/// </remarks>
|
||||
/// <param name="configSelection">The expression that selects the configuration class from the root Data model.</param>
|
||||
/// <param name="propertyExpression">The expression to select the property within the configuration class.</param>
|
||||
/// <param name="defaultValues">The default dictionary of values to use when the setting is not configured.</param>
|
||||
/// <typeparam name="TClass">The type of the configuration class from which the property is selected.</typeparam>
|
||||
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
|
||||
/// <returns>A dictionary containing the default values.</returns>
|
||||
public static Dictionary<int, TValue> Register<TClass, TValue>(Expression<Func<Data, TClass>>? configSelection, Expression<Func<TClass, Dictionary<int, TValue>>> propertyExpression, Dictionary<int, TValue> defaultValues)
|
||||
where TValue : struct, Enum
|
||||
{
|
||||
// When called from the JSON deserializer by using the standard constructor,
|
||||
// we ignore the register call and return the default value:
|
||||
if (configSelection is null)
|
||||
return new();
|
||||
|
||||
var configPath = Path(configSelection, propertyExpression);
|
||||
|
||||
// If the metadata already exists for this configuration path, we return the default value:
|
||||
if (METADATA.ContainsKey(configPath))
|
||||
return defaultValues;
|
||||
|
||||
// Not registered yet, so we register it now:
|
||||
METADATA[configPath] = new ConfigMeta<TClass, Dictionary<int, TValue>>(configSelection, propertyExpression)
|
||||
{
|
||||
Default = defaultValues,
|
||||
};
|
||||
|
||||
return defaultValues;
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,74 @@ public static partial class ManagedConfiguration
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve the configuration metadata for a setting which maps enum keys to texts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When no configuration metadata is found, it returns a NoConfig instance with the default
|
||||
/// value set to an empty dictionary. This allows the caller to handle the absence of configuration
|
||||
/// gracefully. In such cases, the return value of the method will be false.
|
||||
/// </remarks>
|
||||
/// <param name="configSelection">The expression to select the configuration class.</param>
|
||||
/// <param name="propertyExpression">The expression to select the property within the
|
||||
/// configuration class.</param>
|
||||
/// <param name="configMeta">The output parameter that will hold the configuration metadata
|
||||
/// if found.</param>
|
||||
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
|
||||
/// <typeparam name="TKey">The enum type of the dictionary keys.</typeparam>
|
||||
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
|
||||
public static bool TryGet<TClass, TKey>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, Dictionary<TKey, string>>> propertyExpression, out ConfigMeta<TClass, Dictionary<TKey, string>> configMeta)
|
||||
where TKey : struct, Enum
|
||||
{
|
||||
var configPath = Path(configSelection, propertyExpression);
|
||||
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, Dictionary<TKey, string>> meta)
|
||||
{
|
||||
meta.RestoreLockedConfiguration();
|
||||
configMeta = meta;
|
||||
return true;
|
||||
}
|
||||
|
||||
configMeta = new NoConfig<TClass, Dictionary<TKey, string>>(configSelection, propertyExpression)
|
||||
{
|
||||
Default = new Dictionary<TKey, string>(),
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve the configuration metadata for a setting which maps numbers to enum values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When no configuration metadata is found, it returns a NoConfig instance with the default
|
||||
/// value set to an empty dictionary. This allows the caller to handle the absence of configuration
|
||||
/// gracefully. In such cases, the return value of the method will be false.
|
||||
/// </remarks>
|
||||
/// <param name="configSelection">The expression to select the configuration class.</param>
|
||||
/// <param name="propertyExpression">The expression to select the property within the
|
||||
/// configuration class.</param>
|
||||
/// <param name="configMeta">The output parameter that will hold the configuration metadata
|
||||
/// if found.</param>
|
||||
/// <typeparam name="TClass">The type of the configuration class.</typeparam>
|
||||
/// <typeparam name="TValue">The enum type of the dictionary values.</typeparam>
|
||||
/// <returns>True if the configuration metadata was found, otherwise false.</returns>
|
||||
public static bool TryGet<TClass, TValue>(Expression<Func<Data, TClass>> configSelection, Expression<Func<TClass, Dictionary<int, TValue>>> propertyExpression, out ConfigMeta<TClass, Dictionary<int, TValue>> configMeta)
|
||||
where TValue : struct, Enum
|
||||
{
|
||||
var configPath = Path(configSelection, propertyExpression);
|
||||
if (METADATA.TryGetValue(configPath, out var value) && value is ConfigMeta<TClass, Dictionary<int, TValue>> meta)
|
||||
{
|
||||
meta.RestoreLockedConfiguration();
|
||||
configMeta = meta;
|
||||
return true;
|
||||
}
|
||||
|
||||
configMeta = new NoConfig<TClass, Dictionary<int, TValue>>(configSelection, propertyExpression)
|
||||
{
|
||||
Default = new Dictionary<int, TValue>(),
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a configuration plugin may manage a setting, or whether that setting belongs
|
||||
/// to the IT department of an organization.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
namespace AIStudio.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a file or folder a configuration plugin names in a setting, such as the PowerPoint
|
||||
/// template or the picture library of the Slide Assistant, or the icon of a provider.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A plugin may ship the file or folder itself, next to its plugin.lua, and name it by a path
|
||||
/// relative to its directory. Such a path has to stay inside that directory, so a plugin cannot reach
|
||||
/// an arbitrary file elsewhere on the system through it. An absolute path, e.g., on a network share,
|
||||
/// stays as the organization wrote it, where the setting allows one.
|
||||
/// </remarks>
|
||||
internal static class PluginFilePath
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a path against the directory of the plugin which states it.
|
||||
/// </summary>
|
||||
/// <param name="path">The path as the plugin states it: empty, absolute, or relative to the plugin directory.</param>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <param name="resolvedPath">The full path; empty for an empty path, which means no file.</param>
|
||||
/// <param name="issue">Why the path cannot be used; empty on success.</param>
|
||||
/// <returns>Whether the path can be used.</returns>
|
||||
public static bool TryResolve(string path, string pluginPath, out string resolvedPath, out string issue)
|
||||
{
|
||||
resolvedPath = string.Empty;
|
||||
issue = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return true;
|
||||
|
||||
if (Path.IsPathFullyQualified(path))
|
||||
{
|
||||
resolvedPath = path;
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryResolveInside(path, pluginPath, out resolvedPath, out issue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the path of a folder against the directory of the plugin which states it.
|
||||
/// </summary>
|
||||
/// <param name="path">The path as the plugin states it: empty, absolute, or relative to the plugin directory.</param>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <param name="resolvedPath">The full path; empty for an empty path, which means no folder.</param>
|
||||
/// <param name="issue">Why the path cannot be used; empty on success.</param>
|
||||
/// <returns>Whether the path can be used.</returns>
|
||||
public static bool TryResolveDirectory(string path, string pluginPath, out string resolvedPath, out string issue)
|
||||
{
|
||||
resolvedPath = string.Empty;
|
||||
issue = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return true;
|
||||
|
||||
if (Path.IsPathFullyQualified(path))
|
||||
{
|
||||
resolvedPath = path;
|
||||
return true;
|
||||
}
|
||||
|
||||
return TryResolveInside(path, pluginPath, true, out resolvedPath, out issue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a path relative to the directory of a plugin, which has to name an existing file
|
||||
/// inside that directory.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">The path relative to the plugin directory; both kinds of slashes work.</param>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <param name="fullPath">The full path of the file; empty when the path cannot be used.</param>
|
||||
/// <param name="issue">Why the path cannot be used; empty on success.</param>
|
||||
/// <returns>Whether the path names a file inside the plugin directory.</returns>
|
||||
public static bool TryResolveInside(string relativePath, string pluginPath, out string fullPath, out string issue) => TryResolveInside(relativePath, pluginPath, false, out fullPath, out issue);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a path relative to the directory of a plugin, which has to name an existing file or
|
||||
/// folder inside that directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every file and folder a plugin ships itself is checked here, so that all of them follow the
|
||||
/// same rules: no '..' segments, and no link along the path which leads out of the plugin directory.
|
||||
/// </remarks>
|
||||
/// <param name="relativePath">The path relative to the plugin directory; both kinds of slashes work.</param>
|
||||
/// <param name="pluginPath">The directory of the plugin.</param>
|
||||
/// <param name="isDirectory">Whether the path has to name a folder rather than a file.</param>
|
||||
/// <param name="fullPath">The full path; empty when the path cannot be used.</param>
|
||||
/// <param name="issue">Why the path cannot be used; empty on success.</param>
|
||||
/// <returns>Whether the path names a file or folder inside the plugin directory.</returns>
|
||||
private static bool TryResolveInside(string relativePath, string pluginPath, bool isDirectory, out string fullPath, out string issue)
|
||||
{
|
||||
fullPath = string.Empty;
|
||||
issue = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pluginPath))
|
||||
{
|
||||
issue = "The relative path cannot be resolved because the plugin directory is unknown.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalizedPath = relativePath
|
||||
.Replace('/', Path.DirectorySeparatorChar)
|
||||
.Replace('\\', Path.DirectorySeparatorChar);
|
||||
|
||||
if (normalizedPath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == ".."))
|
||||
{
|
||||
issue = "The relative path must not contain '..' path segments.";
|
||||
return false;
|
||||
}
|
||||
|
||||
string pluginRoot;
|
||||
string candidatePath;
|
||||
try
|
||||
{
|
||||
pluginRoot = Path.GetFullPath(pluginPath);
|
||||
candidatePath = Path.GetFullPath(Path.Combine(pluginRoot, normalizedPath));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
issue = $"The relative path is invalid: {e.Message}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!PathTools.IsInsideDirectory(pluginRoot, candidatePath))
|
||||
{
|
||||
issue = "The relative path points outside of the plugin directory.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isDirectory ? !Directory.Exists(candidatePath) : !File.Exists(candidatePath))
|
||||
{
|
||||
issue = isDirectory ? "The folder does not exist in the plugin directory." : "The file does not exist in the plugin directory.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!LinksStayInsideDirectory(pluginRoot, candidatePath))
|
||||
{
|
||||
issue = "The relative path contains a link that points outside of the plugin directory.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
issue = $"The links of the relative path cannot be checked: {e.Message}";
|
||||
return false;
|
||||
}
|
||||
|
||||
fullPath = candidatePath;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether no link along the path leads out of the root directory.
|
||||
/// </summary>
|
||||
private static bool LinksStayInsideDirectory(string rootDirectory, string path)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(rootDirectory, path);
|
||||
var currentPath = Path.GetFullPath(rootDirectory);
|
||||
foreach (var segment in relativePath.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
currentPath = Path.Combine(currentPath, segment);
|
||||
FileSystemInfo pathInfo = Directory.Exists(currentPath) ? new DirectoryInfo(currentPath) : new FileInfo(currentPath);
|
||||
var finalTarget = pathInfo.ResolveLinkTarget(true);
|
||||
if (finalTarget is not null && !PathTools.IsInsideDirectory(rootDirectory, finalTarget.FullName))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -27,52 +27,15 @@ internal static class PluginIconFile
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pluginPath))
|
||||
{
|
||||
issue = "The icon path cannot be resolved because the configuration plugin directory is unknown.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var relativePath = iconPath
|
||||
.Replace('/', Path.DirectorySeparatorChar)
|
||||
.Replace('\\', Path.DirectorySeparatorChar);
|
||||
|
||||
if (relativePath.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == ".."))
|
||||
{
|
||||
issue = "The icon path must not contain '..' path segments.";
|
||||
return false;
|
||||
}
|
||||
|
||||
string pluginRoot;
|
||||
string resolvedPath;
|
||||
try
|
||||
{
|
||||
pluginRoot = Path.GetFullPath(pluginPath);
|
||||
resolvedPath = Path.GetFullPath(Path.Combine(pluginRoot, relativePath));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
issue = $"The icon path is invalid: {e.Message}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!PathTools.IsInsideDirectory(pluginRoot, resolvedPath))
|
||||
{
|
||||
issue = "The icon path points outside of the configuration plugin directory.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(Path.GetExtension(resolvedPath), ".svg", StringComparison.OrdinalIgnoreCase))
|
||||
if (!string.Equals(Path.GetExtension(iconPath), ".svg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
issue = "The icon file must use the .svg extension.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(resolvedPath))
|
||||
{
|
||||
issue = "The icon file does not exist.";
|
||||
// The same rules as for every other file a plugin ships:
|
||||
if (!PluginFilePath.TryResolveInside(iconPath, pluginPath, out var resolvedPath, out issue))
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -84,12 +47,6 @@ internal static class PluginIconFile
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!LinksStayInsideDirectory(pluginRoot, resolvedPath))
|
||||
{
|
||||
issue = "The icon path contains a link that points outside of the configuration plugin directory.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return SvgIcon.TryCreateDataUrl(File.ReadAllBytes(resolvedPath), out dataUrl, out issue);
|
||||
}
|
||||
catch (Exception e)
|
||||
@@ -98,20 +55,4 @@ internal static class PluginIconFile
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool LinksStayInsideDirectory(string rootDirectory, string filePath)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(rootDirectory, filePath);
|
||||
var currentPath = Path.GetFullPath(rootDirectory);
|
||||
foreach (var segment in relativePath.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
currentPath = Path.Combine(currentPath, segment);
|
||||
FileSystemInfo pathInfo = Directory.Exists(currentPath) ? new DirectoryInfo(currentPath) : new FileInfo(currentPath);
|
||||
var finalTarget = pathInfo.ResolveLinkTarget(true);
|
||||
if (finalTarget is not null && !PathTools.IsInsideDirectory(rootDirectory, finalTarget.FullName))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user