瀏覽代碼

Added PostedStatus to IPostable;
Removed generic argument from PosterSettings; also added default script and ScriptEnabled flag for PosterSettings
Added better error checking to CSVPosterEngine;
Added ButtonName, PostableType and THumbnail to PostableSettings;
Improved Process method on PosterEngine
Added caption to posters.
Implemented correct logic for loading poster settings
Added AutoSecurityDescriptors for Postables
Added grids to edit PostableSettings and PosterSettings

Kenric Nugteren 1 年之前
父節點
當前提交
9b88a7ac9c

+ 9 - 0
InABox.Core/Postable/IPostable.cs

@@ -4,6 +4,13 @@ using System.Text;
 
 namespace InABox.Core
 {
+    public enum PostedStatus
+    {
+        NeverPosted,
+        PostFailed,
+        Posted
+    }
+
     /// <summary>
     /// Flags an <see cref="Entity"/> as "Postable"; that is, it can be processed by an <see cref="IPoster{TEntity,TSettings}"/>.
     /// </summary>
@@ -14,5 +21,7 @@ namespace InABox.Core
         /// When the <see cref="IPostable"/> is processed, this should be updated, so that we don't process things twice.
         /// </summary>
         DateTime Posted { get; set; }
+
+        PostedStatus PostedStatus { get; set; }
     }
 }

+ 2 - 2
InABox.Core/Postable/IPoster.cs

@@ -11,10 +11,10 @@ namespace InABox.Core
     /// will be scriptable.
     /// </summary>
     /// <typeparam name="TEntity">The type of entity that this poster can process.</typeparam>
-    /// <typeparam name="TSettings">The <see cref="PosterSettings{TEntity}"/> specific to this type of poster.</typeparam>
+    /// <typeparam name="TSettings">The <see cref="PosterSettings"/> specific to this type of poster.</typeparam>
     public interface IPoster<TEntity, TSettings>
         where TEntity : Entity, IPostable
-        where TSettings : PosterSettings<TEntity>
+        where TSettings : PosterSettings
     {
     }
 }

+ 22 - 5
InABox.Core/Postable/PostableSettings.cs

@@ -6,16 +6,33 @@ using System.Text;
 namespace InABox.Core
 {
     /// <summary>
-    /// The global settings for an <see cref="IPostable"/> of a certain <typeparamref name="TEntity"/>.
+    /// The global settings for an <see cref="IPostable"/>.
     /// </summary>
-    /// <typeparam name="TEntity"></typeparam>
-    public class PostableSettings<TEntity> : IGlobalConfigurationSettings
-        where TEntity : Entity, IPostable
+    public class PostableSettings : BaseObject, IGlobalConfigurationSettings
     {
+        [NullEditor]
+        public string PostableType { get; set; }
+
         /// <summary>
         /// What kind of <see cref="IPoster{TEntity, TSettings}"/> is to be used, globally; this will be a type name.
         /// </summary>
         [ComboLookupEditor(typeof(PosterTypeLookup))]
-        public string PosterType { get; set; }
+        [EditorSequence(1)]
+        public string? PosterType { get; set; }
+
+        [TextBoxEditor]
+        [EditorSequence(2)]
+        public string? ButtonName { get; set; }
+
+        [EditorSequence(3)]
+        public ImageDocumentLink Thumbnail { get; set; }
+
+        protected override void Init()
+        {
+            base.Init();
+
+            PostableType = "";
+            Thumbnail = new ImageDocumentLink();
+        }
     }
 }

+ 74 - 8
InABox.Core/Postable/PosterEngine.cs

@@ -1,4 +1,5 @@
-using InABox.Configuration;
+using InABox.Clients;
+using InABox.Configuration;
 using System;
 using System.Collections.Generic;
 using System.Linq;
@@ -6,16 +7,30 @@ using System.Text;
 
 namespace InABox.Core
 {
+    public class RepostedException : Exception
+    {
+        public RepostedException(): base("Cannot process an item twice.")
+        {
+        }
+    }
+
     public interface IPosterEngine<TPostable>
-        where TPostable : Entity, IPostable
+        where TPostable : Entity, IPostable, IRemotable, IPersistent, new()
     {
         bool Process(IEnumerable<TPostable> posts);
     }
 
-    public abstract class PosterEngine<TPostable, TPoster, TSettings> : IPosterEngine<TPostable>
-        where TPostable : Entity, IPostable
+    public interface IPosterEngine<TPostable, TPoster, TSettings> : IPosterEngine<TPostable>
+        where TPostable : Entity, IPostable, IRemotable, IPersistent, new()
         where TPoster : IPoster<TPostable, TSettings>
-        where TSettings : PosterSettings<TPostable>, new()
+        where TSettings : PosterSettings, new()
+    {
+    }
+
+    public abstract class PosterEngine<TPostable, TPoster, TSettings> : IPosterEngine<TPostable, TPoster, TSettings>
+        where TPostable : Entity, IPostable, IRemotable, IPersistent, new()
+        where TPoster : IPoster<TPostable, TSettings>
+        where TSettings : PosterSettings, new()
     {
         protected static TPoster Poster = GetPoster();
 
@@ -38,14 +53,65 @@ namespace InABox.Core
 
         protected static TSettings GetSettings()
         {
-            return new GlobalConfiguration<TSettings>(typeof(TPostable).Name).Load();
+            return PosterUtils.LoadPosterSettings<TPostable, TSettings>();
+        }
+
+        protected static void SaveSettings(TSettings settings)
+        {
+            PosterUtils.SavePosterSettings<TPostable, TSettings>(settings);
         }
 
+        /// <summary>
+        /// Returns the <see cref="TSettings.Script"/>, if <see cref="TSettings.ScriptEnabled"/> is <see langword="true"/>;
+        /// otherwise, returns <see langword="null"/>.
+        /// </summary>
         protected static string? GetScript()
         {
-            return GetSettings().Script;
+            var settings = GetSettings();
+            return settings.ScriptEnabled ? settings.Script : null;
         }
 
-        public abstract bool Process(IEnumerable<TPostable> posts);
+        protected abstract bool DoProcess(IEnumerable<TPostable> posts);
+
+        public bool Process(IEnumerable<TPostable> posts)
+        {
+            var list = posts.ToList();
+            if(list.Any(x => x.PostedStatus == PostedStatus.Posted))
+            {
+                throw new RepostedException();
+            }
+            try
+            {
+                var success = DoProcess(list);
+                if (success)
+                {
+                    foreach (var post in list)
+                    {
+                        post.Posted = DateTime.Now;
+                        post.PostedStatus = PostedStatus.Posted;
+                    }
+                    new Client<TPostable>().Save(list, "Posted by user.");
+                }
+                else
+                {
+                    foreach (var post in list)
+                    {
+                        post.PostedStatus = PostedStatus.PostFailed;
+                    }
+                    new Client<TPostable>().Save(list, "Post failed by user.");
+                }
+                return success;
+            }
+            catch(Exception e)
+            {
+                Logger.Send(LogType.Error, "", $"Post Failed: {CoreUtils.FormatException(e)}");
+                foreach (var post in list)
+                {
+                    post.PostedStatus = PostedStatus.PostFailed;
+                }
+                new Client<TPostable>().Save(list, "Post failed by user.");
+                throw;
+            }
+        }
     }
 }

+ 24 - 4
InABox.Core/Postable/PosterSettings.cs

@@ -1,6 +1,8 @@
 using InABox.Configuration;
 using System;
 using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
 using System.Text;
 
 namespace InABox.Core
@@ -8,11 +10,29 @@ namespace InABox.Core
     /// <summary>
     /// The settings for a given <see cref="IPoster{TEntity, TSettings}"/>.
     /// </summary>
-    /// <typeparam name="TEntity"></typeparam>
-    public class PosterSettings<TEntity> : IGlobalConfigurationSettings
-        where TEntity : Entity, IPostable
+    public abstract class PosterSettings : BaseObject, IGlobalConfigurationSettings
     {
+        [NullEditor]
+        public string PostableType { get; set; }
+
+        [CheckBoxEditor]
+        public bool ScriptEnabled { get; set; }
+
         [ScriptEditor]
-        public string? Script { get; set; }
+        public string Script { get; set; }
+
+        protected override void Init()
+        {
+            base.Init();
+
+            Script = "";
+            ScriptEnabled = false;
+        }
+
+        public abstract string DefaultScript(Type TPostable);
+        public string DefaultScript<TPostable>() where TPostable : Entity, IPostable
+        {
+            return DefaultScript(typeof(TPostable));
+        }
     }
 }

+ 17 - 7
InABox.Core/Postable/PosterTypeLookup.cs

@@ -1,4 +1,5 @@
 using InABox.Clients;
+using System;
 using System.Linq;
 
 namespace InABox.Core
@@ -7,13 +8,22 @@ namespace InABox.Core
     {
         public PosterTypeLookup(object[]? items) : base(items)
         {
-            var classes = CoreUtils.Entities.Where(
-                x => x.IsSubclassOf(typeof(Entity))
-                && x.HasInterface(typeof(IPoster<,>)))
-                .OrderBy(x => x.EntityName().Split('.').Last()).ToArray();
-            foreach (var entity in classes)
-                if (ClientFactory.IsSupported(entity))
-                    AddValue(entity.EntityName(), entity.EntityName().Split('.').Last());
+            var settings = (items?.FirstOrDefault() as PostableSettings)!;
+
+            var postableType = CoreUtils.GetEntity(settings.PostableType);
+
+            foreach (var entity in PosterUtils.GetPosters())
+                if (ClientFactory.IsSupported(entity)
+                    && entity.GetInterfaceDefinition(typeof(IPoster<,>))!.GenericTypeArguments[0] == postableType)
+                {
+                    AddValue(
+                        entity.EntityName(),
+                        entity.GetCaptionOrNull(true)
+                            ?? entity.GetInterfaces()
+                                .Where(x => x.HasInterface(typeof(IPoster<,>)))
+                                .Select(x => x.GetCaptionOrNull(true)).FirstOrDefault(x => x != null)
+                            ?? entity.EntityName().Split('.').Last());
+                }
         }
     }
 }

+ 129 - 13
InABox.Core/Postable/PosterUtils.cs

@@ -1,42 +1,158 @@
-using System;
+using InABox.Configuration;
+using InABox.Core.Postable;
+using System;
 using System.Collections.Generic;
 using System.Linq;
+using System.Reflection;
+using System.Runtime;
 using System.Text;
 
 namespace InABox.Core
 {
+    namespace Postable
+    {
+        public class MissingSettingsException : Exception
+        {
+            public Type PostableType { get; }
+
+            public MissingSettingsException(Type postableType) : base($"No PostableSettings for ${postableType}")
+            {
+                PostableType = postableType;
+            }
+        }
+    }
+
     public static class PosterUtils
     {
-        private static Dictionary<Type, Type>? _posterEngines;
+        private class EngineType
+        {
+            public Type Engine { get; set; }
+
+            public Type Poster { get; set; }
+        }
+
+        private static EngineType[]? _posterEngines;
+        private static Type[]? _posters = null;
+
+        public static Type[] GetPosters()
+        {
+            _posters ??= CoreUtils.TypeList(
+                AppDomain.CurrentDomain.GetAssemblies(),
+                x => x.IsClass
+                    && !x.IsAbstract
+                    && !x.IsGenericType
+                    && x.HasInterface(typeof(IPoster<,>))).ToArray();
+            return _posters;
+        }
 
-        private static Dictionary<Type, Type> GetPosterEngines()
+        private static EngineType[] GetPosterEngines()
         {
             _posterEngines ??= CoreUtils.TypeList(
                 AppDomain.CurrentDomain.GetAssemblies(),
                 x => x.IsClass
                     && !x.IsAbstract
-                    && x.GenericTypeArguments.Length == 1
-                    && x.HasInterface(typeof(IPosterEngine<>))
-            ).ToDictionary(
-                x => x.GetInterfaceDefinition(typeof(IPosterEngine<>))!.GenericTypeArguments[0],
-                x => x);
+                    && x.GetTypeInfo().GenericTypeParameters.Length == 1
+                    && x.HasInterface(typeof(IPosterEngine<,,>))
+            ).Select(x => new EngineType
+            {
+                Engine = x,
+                Poster = x.GetInterfaceDefinition(typeof(IPosterEngine<,,>))!.GenericTypeArguments[1].GetGenericTypeDefinition()
+            }).ToArray();
             return _posterEngines;
         }
 
-        public static Type GetEngine<T>()
+        private static PostableSettings FixPostableSettings<TPostable>(PostableSettings settings)
+            where TPostable : Entity, IPostable
+        {
+            if (string.IsNullOrWhiteSpace(settings.PostableType))
+            {
+                settings.PostableType = typeof(TPostable).EntityName();
+            }
+            return settings;
+        }
+
+        public static PostableSettings LoadPostableSettings<T>()
             where T : Entity, IPostable
         {
-            return GetPosterEngines().GetValueOrDefault(typeof(T))
-                ?? throw new Exception($"No poster engine for type {typeof(T).Name}");
+            return FixPostableSettings<T>(new GlobalConfiguration<PostableSettings>(typeof(T).Name).Load());
         }
-        public static IPosterEngine<T> CreateEngine<T>()
+
+        public static void SavePostableSettings<T>(PostableSettings settings)
             where T : Entity, IPostable
+        {
+            new GlobalConfiguration<PostableSettings>(typeof(T).Name).Save(FixPostableSettings<T>(settings));
+        }
+
+        private static TSettings FixPosterSettings<TPostable, TSettings>(TSettings settings)
+            where TPostable : IPostable
+            where TSettings : PosterSettings, new()
+        {
+            if (string.IsNullOrWhiteSpace(settings.PostableType))
+            {
+                settings.PostableType = typeof(TPostable).EntityName();
+            }
+            return settings;
+        }
+
+        private static MethodInfo _loadPosterSettingsMethod = typeof(PosterUtils).GetMethods(BindingFlags.Static | BindingFlags.Public)
+            .Where(x => x.Name == nameof(LoadPosterSettings) && x.IsGenericMethod)
+            .Single();
+        private static MethodInfo _savePosterSettingsMethod = typeof(PosterUtils).GetMethods(BindingFlags.Static | BindingFlags.Public)
+            .Where(x => x.Name == nameof(SavePosterSettings) && x.IsGenericMethod)
+            .Single();
+
+        public static TSettings LoadPosterSettings<TPostable, TSettings>()
+            where TPostable : IPostable
+            where TSettings : PosterSettings, new()
+        {
+            return FixPosterSettings<TPostable, TSettings>(new GlobalConfiguration<TSettings>(typeof(TPostable).Name).Load());
+        }
+
+        public static PosterSettings LoadPosterSettings(Type TPostable, Type TSettings)
+        {
+            return (_loadPosterSettingsMethod.MakeGenericMethod(TPostable, TSettings).Invoke(null, Array.Empty<object>()) as PosterSettings)!;
+        }
+
+        public static void SavePosterSettings<TPostable, TSettings>(TSettings settings)
+            where TPostable : IPostable
+            where TSettings : PosterSettings, new()
+        {
+            new GlobalConfiguration<TSettings>(typeof(TPostable).Name).Save(FixPosterSettings<TPostable, TSettings>(settings));
+        }
+
+        public static void SavePosterSettings(Type TPostable, Type TSettings, PosterSettings settings)
+        {
+            _savePosterSettingsMethod.MakeGenericMethod(TPostable, TSettings).Invoke(null, new object[] { settings });
+        }
+
+        /// <summary>
+        /// Get the <see cref="IPosterEngine{TPostable,TPoster,TSettings}"/> for <typeparamref name="T"/>
+        /// based on the current <see cref="PostableSettings"/> for <typeparamref name="T"/>.
+        /// </summary>
+        /// <typeparam name="T"></typeparam>
+        /// <returns></returns>
+        public static Type GetEngine<T>()
+            where T : Entity, IPostable, IRemotable, IPersistent, new()
+        {
+            var settings = LoadPostableSettings<T>();
+            if (string.IsNullOrWhiteSpace(settings.PosterType))
+            {
+                throw new MissingSettingsException(typeof(T));
+            }
+            var poster = GetPosters()?.FirstOrDefault(x => x.EntityName() == settings.PosterType)!;
+
+            return (GetPosterEngines().FirstOrDefault(x => poster.HasInterface(x.Poster))?.Engine
+                ?? throw new Exception("No poster for the given settings.")).MakeGenericType(typeof(T));
+        }
+
+        public static IPosterEngine<T> CreateEngine<T>()
+            where T : Entity, IPostable, IRemotable, IPersistent, new()
         {
             return (Activator.CreateInstance(GetEngine<T>()) as IPosterEngine<T>)!;
         }
 
         public static bool Process<T>(IEnumerable<T> entities)
-            where T : Entity, IPostable
+            where T : Entity, IPostable, IRemotable, IPersistent, new()
         {
             return CreateEngine<T>().Process(entities);
         }

+ 18 - 0
InABox.Core/Security/AutoSecurityDescriptor.cs

@@ -58,6 +58,24 @@ namespace InABox.Core
         public bool Value => typeof(TEntity).GetCustomAttribute<AutoEntity>() == null;
     }
 
+    public class CanPost<TEntity> : IAutoSecurityAction<TEntity>
+    {
+        public string Prefix => "Post";
+
+        public string Postfix => "";
+
+        public bool Value => true;
+    }
+
+    public class CanConfigurePost<TEntity> : IAutoSecurityAction<TEntity>
+    {
+        public string Prefix => "Configure";
+
+        public string Postfix => "Post Settings";
+
+        public bool Value => true;
+    }
+
     public class CanDelete<TEntity> : IAutoSecurityAction<TEntity>
     {
         public string Prefix => "Delete";

+ 21 - 1
InABox.Core/Security/Security.cs

@@ -79,7 +79,17 @@ namespace InABox.Core
                             foreach (var _class in tokens.Where(x => x.GetInterfaces().Contains(typeof(IMergeable))))
                                 CheckAutoToken(_class, typeof(CanMerge<>));
                         });
-                        Task.WaitAll(view, edit, delete, issues, exports, merges);
+                        var posts = Task.Run(() =>
+                        {
+                            foreach (var _class in tokens.Where(x => x.GetInterfaces().Contains(typeof(IPostable))))
+                                CheckAutoToken(_class, typeof(CanPost<>));
+                        });
+                        var configPosts = Task.Run(() =>
+                        {
+                            foreach (var _class in tokens.Where(x => x.GetInterfaces().Contains(typeof(IPostable))))
+                                CheckAutoToken(_class, typeof(CanConfigurePost<>));
+                        });
+                        Task.WaitAll(view, edit, delete, issues, exports, merges, posts, configPosts);
                     });
                     Task.WaitAll(custom, auto);
                 }
@@ -223,6 +233,16 @@ namespace InABox.Core
             return ClientFactory.IsSupported<TEntity>() && IsAllowed<AutoSecurityDescriptor<TEntity, CanMerge<TEntity>>>();
         }
 
+        public static bool CanPost<TEntity>() where TEntity : Entity, new()
+        {
+            return ClientFactory.IsSupported<TEntity>() && IsAllowed<AutoSecurityDescriptor<TEntity, CanPost<TEntity>>>();
+        }
+
+        public static bool CanConfigurePost<TEntity>() where TEntity : Entity, new()
+        {
+            return ClientFactory.IsSupported<TEntity>() && IsAllowed<AutoSecurityDescriptor<TEntity, CanConfigurePost<TEntity>>>();
+        }
+
         public static bool CanDelete<TEntity>() where TEntity : Entity, new()
         {
             return ClientFactory.IsSupported<TEntity>() && IsAllowed<AutoSecurityDescriptor<TEntity, CanDelete<TEntity>>>();

+ 13 - 7
InABox.Poster.CSV/CSVPosterEngine.cs

@@ -12,24 +12,30 @@ using System.Threading.Tasks;
 
 namespace InABox.Poster.CSV
 {
-    public class CSVPosterEngine<TPostable> : PosterEngine<TPostable, ICSVPoster<TPostable>, CSVPosterSettings<TPostable>>
-        where TPostable : Entity, IPostable
+    public class CSVPosterEngine<TPostable> : PosterEngine<TPostable, ICSVPoster<TPostable>, CSVPosterSettings>
+        where TPostable : Entity, IPostable, IRemotable, IPersistent, new()
     {
-        public override bool Process(IEnumerable<TPostable> posts)
+        protected override bool DoProcess(IEnumerable<TPostable> posts)
         {
             var settings = GetSettings();
 
             ICSVExport results;
-            if(!string.IsNullOrWhiteSpace(settings.Script))
+            if(settings.ScriptEnabled && !string.IsNullOrWhiteSpace(settings.Script))
             {
                 var document = new ScriptDocument(settings.Script);
                 document.Properties.Add(new ScriptProperty("Results", null));
-                document.Compile();
-                document.Execute(methodname: "Process");
+                if (!document.Compile())
+                {
+                    throw new Exception("Script failed to compile!");
+                }
+                if(!document.Execute(methodname: "Process", parameters: new object[] { posts }))
+                {
+                    return false;
+                }
 
                 var resultsObject = document.GetValue("Results");
                 results = (resultsObject as ICSVExport)
-                    ?? throw new Exception($"Script Results property expected to be ICSVExport, got {resultsObject}");
+                    ?? throw new Exception($"Script 'Results' property expected to be ICSVExport, got {resultsObject}");
             }
             else
             {

+ 46 - 2
InABox.Poster.CSV/CSVPosterSettings.cs

@@ -1,4 +1,5 @@
 using InABox.Core;
+using Inflector;
 using System;
 using System.Collections.Generic;
 using System.Linq;
@@ -7,10 +8,53 @@ using System.Threading.Tasks;
 
 namespace InABox.Poster.CSV
 {
-    public class CSVPosterSettings<TEntity> : PosterSettings<TEntity>
-        where TEntity : Entity, IPostable
+    public class CSVPosterSettings : PosterSettings
     {
         [FileNameEditor("CSV Files (*.csv)|*.csv", RequireExisting = false)]
         public string DefaultOutputFile { get; set; }
+
+        public override string DefaultScript(Type TPostable)
+        {
+            var tName = TPostable.Name;
+            var decapital = tName[0..1].ToLower() + tName[1..];
+
+            var ns = TPostable.Namespace;
+
+            return @"
+using " + ns + @";
+using InABox.Poster.CSV;
+using System.Collections.Generic;
+
+public class Module
+{
+    // Output Results for CSV
+    public ICSVExport Results { get; set; }
+
+    public bool Process(IEnumerable<" + tName + @"> items)
+    {
+        // Create new export object. You can use any object as your map,
+        // but for simple cases just use " + tName +  @"
+        var export = new CSVExport<" + tName + @">();
+
+        // Define the mapping from the fields of " + tName + @" to columns in the CSV file.
+        export.DefineMapping(new()
+        {
+            new(""ID"", " + decapital + @" => " + decapital +  @".ID),
+            new(""Column1"", " + decapital + @" => " + decapital +  @".Column1),
+            new(""Column2"", " + decapital + @" => " + decapital +  @".Column2),
+            new(""Column3"", " + decapital + @" => " + decapital + @".Column3)
+            // etc.
+        });
+        foreach(var item in items)
+        {
+            // Do processing
+            export.Add(item);
+        }
+
+        Results = export; // Set result
+        return true; // return true for success.
+    }
+}";
+        }
     }
 }

+ 2 - 1
InABox.Poster.CSV/ICSVPoster.cs

@@ -87,7 +87,8 @@ namespace InABox.Poster.CSV
     /// Defines an interface for posters that can export to CSV.
     /// </summary>
     /// <typeparam name="TEntity"></typeparam>
-    public interface ICSVPoster<TEntity> : IPoster<TEntity, CSVPosterSettings<TEntity>>
+    [Caption("CSV")]
+    public interface ICSVPoster<TEntity> : IPoster<TEntity, CSVPosterSettings>
         where TEntity : Entity, IPostable
     {
         ICSVExport Process(IEnumerable<TEntity> entities);

+ 60 - 0
inabox.wpf/Grids/PostableSettingsGrid.cs

@@ -0,0 +1,60 @@
+using InABox.Core;
+using InABox.DynamicGrid;
+using InABox.Wpf.Grids;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Wpf
+{
+    public class PostableSettingsGrid : DynamicItemsListGrid<PostableSettings>
+    {
+        public PostableSettingsGrid()
+        {
+            OnCustomiseEditor += PostableSettingsGrid_OnCustomiseEditor;
+            OnEditorValueChanged += PostableSettingsGrid_OnEditorValueChanged;
+        }
+
+        private Dictionary<string, object?> PostableSettingsGrid_OnEditorValueChanged(object sender, string name, object value)
+        {
+            var editorForm = (IDynamicEditorForm)sender;
+            if (name == nameof(PostableSettings.PosterType))
+            {
+                var editor = (editorForm.FindEditor(name) as LookupEditorControl)!;
+                (editor.EditorDefinition as ComboLookupEditor)!.Buttons![0].SetEnabled(!string.IsNullOrWhiteSpace(value as string));
+            }
+            return new();
+        }
+
+        private void PostableSettingsGrid_OnCustomiseEditor(IDynamicEditorForm sender, PostableSettings[]? items, DynamicGridColumn column, BaseEditor editor)
+        {
+            var settings = items?.FirstOrDefault();
+            if (settings is null) return;
+
+            if(column.ColumnName == nameof(PostableSettings.PosterType) && editor is ComboLookupEditor combo)
+            {
+                var settingsButton = new EditorButton(settings, "Settings", 60, ViewSettings, false);
+                settingsButton.SetEnabled(!string.IsNullOrWhiteSpace(settings.PosterType));
+                combo.Buttons = new EditorButton[] { settingsButton };
+            }
+        }
+
+        private void ViewSettings(object editor, object? item)
+        {
+            if (item is not PostableSettings settings) return;
+
+            var entityType = CoreUtils.GetEntity(settings.PostableType);
+            var posterType = CoreUtils.GetEntity(settings.PosterType!);
+            var settingsType = posterType.GetInterfaceDefinition(typeof(IPoster<,>))!.GenericTypeArguments[1];
+
+            var posterSettings = PosterUtils.LoadPosterSettings(entityType, settingsType);
+            var grid = DynamicGridUtils.CreateDynamicGrid(typeof(PosterSettingsGrid<>), settingsType);
+            if(grid.EditItems(new object[] { posterSettings }))
+            {
+                PosterUtils.SavePosterSettings(entityType, settingsType, posterSettings);
+            }
+        }
+    }
+}

+ 45 - 0
inabox.wpf/Grids/PosterSettingsGrid.cs

@@ -0,0 +1,45 @@
+using InABox.Core;
+using InABox.DynamicGrid;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace InABox.Wpf.Grids
+{
+    public class PosterSettingsGrid<TPosterSettings> : DynamicItemsListGrid<TPosterSettings>
+        where TPosterSettings : PosterSettings, new()
+    {
+        public PosterSettingsGrid()
+        {
+            OnCustomiseEditor += PosterSettingsGrid_OnCustomiseEditor;
+        }
+
+        private void PosterSettingsGrid_OnCustomiseEditor(IDynamicEditorForm sender, TPosterSettings[]? items, DynamicGridColumn column, BaseEditor editor)
+        {
+            if (items?.FirstOrDefault() is not TPosterSettings settings) return;
+
+            if(column.ColumnName == nameof(PosterSettings.Script) && editor is ScriptEditor scriptEditor)
+            {
+                var tPostable = CoreUtils.GetEntity(settings.PostableType);
+
+                scriptEditor.Type = ScriptEditorType.TemplateEditor;
+                scriptEditor.OnEditorClicked += () =>
+                {
+                    var script = settings.Script.NotWhiteSpaceOr()
+                        ?? settings.DefaultScript(tPostable);
+
+                    var editor = new ScriptEditorWindow(script, SyntaxLanguage.CSharp);
+                    if (editor.ShowDialog() == true)
+                    {
+                        sender.SetEditorValue(column.ColumnName, editor.Script);
+                        settings.Script = editor.Script;
+                        settings.ScriptEnabled = !settings.Script.IsNullOrWhiteSpace();
+                        sender.SetEditorValue(nameof(PosterSettings.ScriptEnabled), settings.ScriptEnabled);
+                    }
+                };
+            }
+        }
+    }
+}