diff --git a/zero.Backoffice/Configuration/BackofficeOptions.cs b/zero.Backoffice/Configuration/BackofficeOptions.cs
index dd7b71da..688f6901 100644
--- a/zero.Backoffice/Configuration/BackofficeOptions.cs
+++ b/zero.Backoffice/Configuration/BackofficeOptions.cs
@@ -2,11 +2,6 @@
public class BackofficeOptions
{
- ///
- /// URL path to the backoffice (defaults to /zero)
- ///
- public string Path { get; set; }
-
///
/// Paths in the backoffice which are not handled by zero
///
diff --git a/zero.Backoffice/Modules/Pages/PageTreeService.cs b/zero.Backoffice/Modules/Pages/PageTreeService.cs
new file mode 100644
index 00000000..51f4085e
--- /dev/null
+++ b/zero.Backoffice/Modules/Pages/PageTreeService.cs
@@ -0,0 +1,150 @@
+using Raven.Client.Documents;
+using Raven.Client.Documents.Linq;
+using Raven.Client.Documents.Session;
+
+namespace zero.Backoffice.Modules;
+
+public class PageTreeService : IPageTreeService
+{
+ protected IPagesStore Pages { get; private set; }
+
+ protected IRoutes Routes { get; set; }
+
+ protected PageOptions PageOptions { get; set; }
+
+
+ public PageTreeService(IPagesStore pages, IZeroOptions options, IRoutes routes)
+ {
+ Pages = pages;
+ Routes = routes;
+ PageOptions = options.For();
+ }
+
+
+ ///
+ public async Task> GetChildren(string parentId = null, string activeId = null, string search = null)
+ {
+ IList items = new List();
+ IReadOnlyCollection pageTypes = PageOptions.GetAllItems();
+ string[] openIds = new string[0] { };
+ Paged pages = null;
+ IList children = null;
+ bool isSearch = !search.IsNullOrWhiteSpace();
+
+ if (isSearch)
+ {
+ pages = await Pages.Load(1, Int32.MaxValue, q => q.SearchIf(x => x.Name, search, "*").OrderBy(x => x.Sort, OrderingType.Long));
+
+ var urls = await Routes.GetUrls(pages.Items.ToArray());
+
+ foreach (Page page in pages.Items)
+ {
+ if (urls.TryGetValue(page, out string url))
+ {
+ page.Url = url;
+ }
+ }
+ }
+ else
+ {
+ pages = await Pages.Load(1, Int32.MaxValue, q => q
+ .WhereIf(x => x.ParentId == parentId, !parentId.IsNullOrEmpty(), x => x.ParentId == null)
+ .OrderBy(x => x.Sort, OrderingType.Long));
+
+
+ // get hierarchy so we know if we should set the page to open
+ if (!activeId.IsNullOrEmpty())
+ {
+ Pages_ByHierarchy.Result result = await Pages.Session.Query()
+ .ProjectInto()
+ .Include(x => x.Path.Select(p => p.Id))
+ .FirstOrDefaultAsync(x => x.Id == activeId);
+
+ if (result != null)
+ {
+ openIds = result.Path.Select(x => x.Id).ToArray(); // .Union(new string[1] { activeId })
+ }
+ }
+
+
+ // get children for all pages
+ string[] pageIds = pages.Items.Select(x => x.Id).ToArray();
+
+ children = await Pages.Session.Query()
+ .ProjectInto()
+ .Where(x => x.Id.In(pageIds))
+ .ToListAsync();
+ }
+
+
+ // function to get modifier icon
+ TreeItemModifier GetModifier(Page page)
+ {
+ if (page.PublishDate > DateTimeOffset.Now || page.UnpublishDate > DateTimeOffset.Now)
+ {
+ return new TreeItemModifier("@page.schedule.scheduled", "fth-clock");
+ }
+ if (!page.IsActive)
+ {
+ return new TreeItemModifier("@ui.inactive", "fth-minus-circle color-red");
+ }
+ return null;
+ }
+
+
+ // build tree
+ foreach (Page page in pages.Items)
+ {
+ PageType pageType = pageTypes.FirstOrDefault(x => x.Alias == page.PageTypeAlias);
+
+ if (pageType == null)
+ {
+ continue;
+ // TODO the page type does not exist anymore
+ }
+
+ int childCount = isSearch ? 0 : children.Count(x => x.Id == page.Id);
+
+ items.Add(new TreeItem()
+ {
+ Id = page.Id,
+ Name = page.Name,
+ HasChildren = childCount > 0,
+ ChildCount = childCount,
+ ParentId = page.ParentId,
+ Sort = page.Sort,
+ Icon = pageType.Icon,
+ IsOpen = openIds.Contains(page.Id),
+ IsInactive = !page.IsActive,
+ HasActions = true,
+ Modifier = GetModifier(page),
+ Description = isSearch ? page.Url : null
+ });
+ }
+
+ if (parentId.IsNullOrEmpty())
+ {
+ items.Add(new TreeItem()
+ {
+ Id = "recyclebin",
+ ParentId = null,
+ Sort = 99999,
+ Name = "@recyclebin.name",
+ Icon = "fth-trash",
+ HasChildren = false,
+ HasActions = true
+ });
+ }
+
+ return items;
+ }
+}
+
+
+public interface IPageTreeService
+{
+ ///
+ /// Get page children as tree items
+ ///
+ Task> GetChildren(string parentId = null, string activeId = null, string search = null);
+}
\ No newline at end of file
diff --git a/zero.Backoffice/Modules/Pages/PagesController.cs b/zero.Backoffice/Modules/Pages/PagesController.cs
new file mode 100644
index 00000000..31c2b91a
--- /dev/null
+++ b/zero.Backoffice/Modules/Pages/PagesController.cs
@@ -0,0 +1,66 @@
+using Microsoft.AspNetCore.Mvc;
+
+namespace zero.Backoffice.Modules;
+
+///
+/// | GET /zero/api/pages/empty
+/// | GET /zero/api/pages/{id}
+/// | GET /zero/api/pages/{id}/revisions
+/// | GET /zero/api/pages[type,query,filter,...]
+/// | POST /zero/api/pages
+/// | PUT /zero/api/pages/{id}
+/// | DELETE /zero/api/pages/{id}
+///
+public class PagesController : ZeroBackofficeApiController
+{
+ protected IPagesStore Store { get; set; }
+
+ public PagesController(IPagesStore store)
+ {
+ Store = store;
+ }
+
+
+ [HttpGet("empty")]
+ public virtual async Task New()
+ {
+ return await Store.Empty();
+ }
+
+ [HttpGet("{id}")]
+ public virtual async Task Get(string id, string changeVector = null)
+ {
+ return await Store.Load(id, changeVector);
+ }
+
+ [HttpGet]
+ public virtual async Task> Get(ListBackofficeQuery query)
+ {
+ return await Store.Load(query.Page, query.PageSize, q => q.OrderByDescending(x => x.CreatedDate));
+ }
+
+ //[HttpGet("{id}/revisions")]
+ //public virtual async Task>> GetRevisions(string id)
+ //{
+ // return await Collection.GetRevisions(id);
+ //}
+
+
+ [HttpPost]
+ public virtual async Task> Create(Country model)
+ {
+ return await Store.Create(model);
+ }
+
+ [HttpPut("{id}")]
+ public virtual async Task> Update(string id, Country model)
+ {
+ return await Store.Update(model);
+ }
+
+ [HttpDelete("{id}")]
+ public virtual async Task> Delete(string id)
+ {
+ return await Store.Delete(id);
+ }
+}
\ No newline at end of file
diff --git a/zero.Backoffice/Modules/Pages/ServiceCollectionExtensions.cs b/zero.Backoffice/Modules/Pages/ServiceCollectionExtensions.cs
new file mode 100644
index 00000000..2df74e2b
--- /dev/null
+++ b/zero.Backoffice/Modules/Pages/ServiceCollectionExtensions.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace zero.Backoffice.Modules;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddZeroBackofficePages(this IServiceCollection services)
+ {
+ services.AddScoped();
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/zero.Backoffice/Modules/Translations/TranslationsController.cs b/zero.Backoffice/Modules/Translations/TranslationsController.cs
index c9ac78a4..e3a0fe69 100644
--- a/zero.Backoffice/Modules/Translations/TranslationsController.cs
+++ b/zero.Backoffice/Modules/Translations/TranslationsController.cs
@@ -10,9 +10,9 @@ using zero.Core.Identity;
namespace zero.Web.Controllers
{
[ZeroAuthorize(Permissions.Settings.Translations, PermissionsValue.Read)]
- public class TranslationsController : ZeroBackofficeCollectionController
+ public class TranslationsController : ZeroBackofficeCollectionController
{
- public TranslationsController(ITranslationsCollection collection) : base(collection) { }
+ public TranslationsController(ITranslationsStore collection) : base(collection) { }
public override async Task> GetByQuery([FromQuery] ListBackofficeQuery query)
{
diff --git a/zero.Backoffice/Resources/Localization/zero.en-us.json b/zero.Backoffice/Resources/Localization/zero.en-us.json
index df1ba925..455bfa76 100644
--- a/zero.Backoffice/Resources/Localization/zero.en-us.json
+++ b/zero.Backoffice/Resources/Localization/zero.en-us.json
@@ -215,6 +215,10 @@
"parentnotallowed": "The page is not allowed as a children of the selected parent",
"sortingmultipleparents": "Sorting pages is only allowed for a specific parent page"
},
+ "treeentity": {
+ "parentnotallowed": "This entity is not allowed as a children of the selected parent",
+ "sortingmultipleparents": "The selected entities have multiple parents, which is not allowed"
+ },
"forms": {
"email_invalid": "The email address is not valid",
"emails_invalid": "The email address(es) is/are not valid",
diff --git a/zero.Backoffice/_legacy/Controllers/PagesController.cs b/zero.Backoffice/_legacy/Controllers/PagesController.cs
index 25bcd226..78a6ee79 100644
--- a/zero.Backoffice/_legacy/Controllers/PagesController.cs
+++ b/zero.Backoffice/_legacy/Controllers/PagesController.cs
@@ -12,11 +12,11 @@ using zero.Web.Models;
namespace zero.Web.Controllers
{
- public class PagesController : BackofficeCollectionController
+ public class PagesController : BackofficeCollectionController
{
IRoutes Routes;
- public PagesController(IPagesCollection collection, IRoutes routes) : base(collection)
+ public PagesController(IPageService collection, IRoutes routes) : base(collection)
{
Routes = routes;
}
diff --git a/zero.Backoffice/_legacy/Defaults/ZeroBackofficePlugin.cs b/zero.Backoffice/_legacy/Defaults/ZeroBackofficePlugin.cs
index 9238c5ab..e4dd2169 100644
--- a/zero.Backoffice/_legacy/Defaults/ZeroBackofficePlugin.cs
+++ b/zero.Backoffice/_legacy/Defaults/ZeroBackofficePlugin.cs
@@ -21,7 +21,7 @@ namespace zero.Web.Defaults
services.AddTransient();
services.AddTransient();
- services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
diff --git a/zero.Core/Applications/ApplicationResolver.cs b/zero.Core/Applications/ApplicationResolver.cs
index 9f9b3feb..f04a4c60 100644
--- a/zero.Core/Applications/ApplicationResolver.cs
+++ b/zero.Core/Applications/ApplicationResolver.cs
@@ -41,7 +41,7 @@ public class ApplicationResolver : IApplicationResolver
Application app;
- if (context.IsBackofficeRequest(Options.BackofficePath))
+ if (context.IsBackofficeRequest(Options.ZeroPath))
{
app = await ResolveFromUser(user);
}
diff --git a/zero.Core/Applications/ApplicationStore.cs b/zero.Core/Applications/ApplicationStore.cs
new file mode 100644
index 00000000..056da224
--- /dev/null
+++ b/zero.Core/Applications/ApplicationStore.cs
@@ -0,0 +1,13 @@
+namespace zero.Applications;
+
+public class ApplicationStore : EntityStore, IApplicationStore
+{
+ public ApplicationStore(IStoreContext context) : base(context)
+ {
+ Config.Database = Options.For().Database;
+ }
+}
+
+public interface IApplicationStore : IEntityStore
+{
+}
\ No newline at end of file
diff --git a/zero.Core/Applications/ServiceCollectionExtensions.cs b/zero.Core/Applications/ServiceCollectionExtensions.cs
index f0a444a8..74aaf7f0 100644
--- a/zero.Core/Applications/ServiceCollectionExtensions.cs
+++ b/zero.Core/Applications/ServiceCollectionExtensions.cs
@@ -7,6 +7,7 @@ internal static class ServiceCollectionExtensions
public static IServiceCollection AddZeroApplications(this IServiceCollection services)
{
services.AddScoped();
+ services.AddScoped();
return services;
}
}
\ No newline at end of file
diff --git a/zero.Core/Configuration/ServiceCollectionExtensions.cs b/zero.Core/Configuration/ServiceCollectionExtensions.cs
index dbc3d293..a89ad3a6 100644
--- a/zero.Core/Configuration/ServiceCollectionExtensions.cs
+++ b/zero.Core/Configuration/ServiceCollectionExtensions.cs
@@ -8,7 +8,11 @@ internal static class ServiceCollectionExtensions
{
public static IServiceCollection AddZeroConfiguration(this IServiceCollection services, IConfiguration config)
{
- services.AddOptions().Bind(config.GetSection("Zero")).Configure(opts => { });
+ services.AddOptions().Bind(config.GetSection("Zero")).Configure(opts =>
+ {
+ opts.Version = "0.0.1-alpha.1";
+ opts.ZeroPath = "/zero";
+ });
services.AddTransient(factory => factory.GetService>().CurrentValue);
return services;
}
diff --git a/zero.Core/Configuration/ZeroOptions.cs b/zero.Core/Configuration/ZeroOptions.cs
index cfa5d7a5..c97ed144 100644
--- a/zero.Core/Configuration/ZeroOptions.cs
+++ b/zero.Core/Configuration/ZeroOptions.cs
@@ -13,7 +13,6 @@ public class ZeroOptions : IZeroOptions
public ZeroOptions(IServiceProvider serviceProvider)
{
- Version = "0.0.1-alpha.1";
ServiceProvider = serviceProvider;
//SupportedLanguages = new string[2] { "en-US", "de-DE" };
@@ -49,7 +48,10 @@ public class ZeroOptions : IZeroOptions
public bool Initialized => !String.IsNullOrEmpty(For()?.Database);
///
- public string Version { get; private set; }
+ public string Version { get; set; }
+
+ ///
+ public string ZeroPath { get; set; }
/////
//public string DefaultLanguage { get; set; }
@@ -92,11 +94,16 @@ public class ZeroOptions : IZeroOptions
public interface IZeroOptions
{
+ ///
+ /// Path to the backoffice. Defaults to /zero
+ ///
+ string ZeroPath { get; set; }
+
///
/// The currently active version
/// This should not be set manually, as it is used for setup and migrations and incremented automatically
///
- string Version { get; }
+ string Version { get; set; }
///
/// Whether this zero instance is initialized (setup is completed)
diff --git a/zero.Core/Stores/CountriesStore.cs b/zero.Core/Localization/CountriesStore.cs
similarity index 62%
rename from zero.Core/Stores/CountriesStore.cs
rename to zero.Core/Localization/CountriesStore.cs
index a17c37e6..ba1c108d 100644
--- a/zero.Core/Stores/CountriesStore.cs
+++ b/zero.Core/Localization/CountriesStore.cs
@@ -1,10 +1,10 @@
using FluentValidation;
-namespace zero.Stores;
+namespace zero.Localization;
-public class CountriesStore : CachableEntityStore, ICountriesStore
+public class CountriesStore : EntityStore, ICountriesStore
{
- public CountriesStore(IStoreContext context, IStoreCache cache) : base(context, cache) { }
+ public CountriesStore(IStoreContext context) : base(context) { }
///
protected override void ValidationRules(ZeroValidator validator)
diff --git a/zero.Core/Localization/CultureService.cs b/zero.Core/Localization/CultureService.cs
new file mode 100644
index 00000000..18f56d1c
--- /dev/null
+++ b/zero.Core/Localization/CultureService.cs
@@ -0,0 +1,31 @@
+using System.Globalization;
+
+namespace zero.Localization;
+
+public class CultureService : ICultureService
+{
+ ///
+ public List GetAllCultures(params string[] codes)
+ {
+ return CultureInfo.GetCultures(CultureTypes.AllCultures)
+ .Where(x => !x.Name.IsNullOrWhiteSpace())
+ .Select(x => new CultureInfo(x.Name))
+ .Where(x => codes.Length > 0 ? codes.Contains(x.Name, StringComparer.InvariantCultureIgnoreCase) : true)
+ .OrderBy(x => x.DisplayName)
+ .Select(x => new Culture()
+ {
+ Code = x.Name,
+ Name = x.DisplayName
+ })
+ .ToList();
+ }
+}
+
+
+public interface ICultureService
+{
+ ///
+ /// Get all available cultures
+ ///
+ List GetAllCultures(params string[] codes);
+}
\ No newline at end of file
diff --git a/zero.Core/Stores/LanguagesStore.cs b/zero.Core/Localization/LanguagesStore.cs
similarity index 60%
rename from zero.Core/Stores/LanguagesStore.cs
rename to zero.Core/Localization/LanguagesStore.cs
index 670c762e..f26bba99 100644
--- a/zero.Core/Stores/LanguagesStore.cs
+++ b/zero.Core/Localization/LanguagesStore.cs
@@ -1,32 +1,11 @@
using FluentValidation;
-using Raven.Client.Documents;
-using Raven.Client.Documents.Linq;
-using System.Globalization;
-namespace zero.Stores;
+namespace zero.Localization;
public class LanguagesStore : EntityStore, ILanguagesStore
{
public LanguagesStore(IStoreContext context) : base(context) { }
-
- ///
- public List GetAllCultures(params string[] codes)
- {
- return CultureInfo.GetCultures(CultureTypes.AllCultures)
- .Where(x => !x.Name.IsNullOrWhiteSpace())
- .Select(x => new CultureInfo(x.Name))
- .Where(x => codes.Length > 0 ? codes.Contains(x.Name, StringComparer.InvariantCultureIgnoreCase) : true)
- .OrderBy(x => x.DisplayName)
- .Select(x => new Culture()
- {
- Code = x.Name,
- Name = x.DisplayName
- })
- .ToList();
- }
-
-
///
protected override void ValidationRules(ZeroValidator validator)
{
@@ -42,10 +21,4 @@ public class LanguagesStore : EntityStore, ILanguagesStore
}
-public interface ILanguagesStore : IEntityStore
-{
- ///
- /// Get all available cultures
- ///
- List GetAllCultures(params string[] codes);
-}
\ No newline at end of file
+public interface ILanguagesStore : IEntityStore { }
\ No newline at end of file
diff --git a/zero.Core/Localization/ServiceCollectionExtensions.cs b/zero.Core/Localization/ServiceCollectionExtensions.cs
index 260aac34..0a013256 100644
--- a/zero.Core/Localization/ServiceCollectionExtensions.cs
+++ b/zero.Core/Localization/ServiceCollectionExtensions.cs
@@ -7,6 +7,9 @@ internal static class ServiceCollectionExtensions
public static IServiceCollection AddZeroLocalization(this IServiceCollection services)
{
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.AddScoped();
return services;
}
diff --git a/zero.Core/Localization/TranslationsStore.cs b/zero.Core/Localization/TranslationsStore.cs
new file mode 100644
index 00000000..723c5488
--- /dev/null
+++ b/zero.Core/Localization/TranslationsStore.cs
@@ -0,0 +1,31 @@
+using FluentValidation;
+
+namespace zero.Localization;
+
+public class TranslationsStore : EntityStore, ITranslationsStore
+{
+ public TranslationsStore(IStoreContext context) : base(context) { }
+
+
+ ///
+ public async Task LoadString(string id)
+ {
+ return (await Load(id))?.Value;
+ }
+
+
+ protected override void ValidationRules(ZeroValidator validator)
+ {
+ validator.RuleFor(x => x.Key).Length(2, 300).Unique(Context.Store);
+ validator.RuleFor(x => x.Value).MaximumLength(10 * 1000);
+ }
+}
+
+
+public interface ITranslationsStore : IEntityStore
+{
+ ///
+ /// Get a translated string by id
+ ///
+ Task LoadString(string id);
+}
\ No newline at end of file
diff --git a/zero.Core/Stores/MailTemplatesStore.cs b/zero.Core/Mails/MailTemplatesStore.cs
similarity index 97%
rename from zero.Core/Stores/MailTemplatesStore.cs
rename to zero.Core/Mails/MailTemplatesStore.cs
index 3ad6ef72..3e54fe7a 100644
--- a/zero.Core/Stores/MailTemplatesStore.cs
+++ b/zero.Core/Mails/MailTemplatesStore.cs
@@ -1,7 +1,7 @@
using FluentValidation;
using Raven.Client.Documents;
-namespace zero.Stores;
+namespace zero.Mails;
public class MailTemplatesStore : EntityStore, IMailTemplatesStore
{
diff --git a/zero.Core/Mails/ServiceCollectionExtensions.cs b/zero.Core/Mails/ServiceCollectionExtensions.cs
index 6165788b..3d89191a 100644
--- a/zero.Core/Mails/ServiceCollectionExtensions.cs
+++ b/zero.Core/Mails/ServiceCollectionExtensions.cs
@@ -8,6 +8,7 @@ internal static class ServiceCollectionExtensions
{
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
return services;
}
}
\ No newline at end of file
diff --git a/zero.Core/Models/IZeroTreeEntity.cs b/zero.Core/Models/IZeroTreeEntity.cs
new file mode 100644
index 00000000..5336cb19
--- /dev/null
+++ b/zero.Core/Models/IZeroTreeEntity.cs
@@ -0,0 +1,19 @@
+namespace zero.Models;
+
+public interface IZeroTreeEntity
+{
+ ///
+ /// Id of the entity
+ ///
+ string Id { get; set; }
+
+ ///
+ /// Parent id
+ ///
+ string ParentId { get; set; }
+
+ ///
+ /// Sort order
+ ///
+ uint Sort { get; set; }
+}
\ No newline at end of file
diff --git a/zero.Core/Pages/Entities/Page.cs b/zero.Core/Pages/Models/Page.cs
similarity index 94%
rename from zero.Core/Pages/Entities/Page.cs
rename to zero.Core/Pages/Models/Page.cs
index b9d73012..82085c47 100644
--- a/zero.Core/Pages/Entities/Page.cs
+++ b/zero.Core/Pages/Models/Page.cs
@@ -5,7 +5,7 @@
/// The backoffice rendering is done by an IRenderer
///
[RavenCollection("Pages")]
-public class Page : ZeroEntity
+public class Page : ZeroEntity, IZeroTreeEntity
{
///
/// Use this field (when filled out) instead of the alias for URL generation
diff --git a/zero.Core/Pages/Entities/PageFolder.cs b/zero.Core/Pages/Models/PageFolder.cs
similarity index 100%
rename from zero.Core/Pages/Entities/PageFolder.cs
rename to zero.Core/Pages/Models/PageFolder.cs
diff --git a/zero.Core/Pages/Entities/PageType.cs b/zero.Core/Pages/Models/PageType.cs
similarity index 100%
rename from zero.Core/Pages/Entities/PageType.cs
rename to zero.Core/Pages/Models/PageType.cs
diff --git a/zero.Core/Pages/PageTypeService.cs b/zero.Core/Pages/PageTypeService.cs
new file mode 100644
index 00000000..e0d70ffc
--- /dev/null
+++ b/zero.Core/Pages/PageTypeService.cs
@@ -0,0 +1,90 @@
+using Raven.Client.Documents;
+using Raven.Client.Documents.Linq;
+
+namespace zero.Pages;
+
+public class PageTypeService : IPageTypeService
+{
+ protected IZeroContext Context { get; private set; }
+
+ protected IHandlerHolder Handler { get; private set; }
+
+ protected PageOptions PageOptions { get; set; }
+
+
+ public PageTypeService(IZeroContext context, IZeroOptions options, IHandlerHolder handler)
+ {
+ Context = context;
+ Handler = handler;
+ PageOptions = options.For();
+ }
+
+
+ ///
+ public IList GetPageTypes()
+ {
+ return PageOptions.GetAllItems().ToList();
+ }
+
+
+ ///
+ public PageType GetPageType(string pageTypeAlias)
+ {
+ return PageOptions.GetAllItems().FirstOrDefault(x => x.Alias == pageTypeAlias);
+ }
+
+
+ ///
+ public async Task> GetAllowedPageTypes(string pageParentId = null)
+ {
+ IEnumerable types = PageOptions.GetAllItems();
+ List parents = new();
+
+ var session = Context.Store.Session();
+
+ if (!pageParentId.IsNullOrEmpty())
+ {
+ Pages_ByHierarchy.Result result = await session.Query()
+ .ProjectInto()
+ .Include(x => x.Id)
+ .Include(x => x.Path.Select(p => p.Id))
+ .FirstOrDefaultAsync(x => x.Id == pageParentId);
+
+ if (result != null)
+ {
+ List ids = result.Path.Select(x => x.Id).ToList();
+ ids.Add(result.Id);
+ parents = (await session.LoadAsync(ids)).Select(x => x.Value).Reverse().ToList();
+ }
+ }
+
+ IPageTypeHandler handler = Handler.Get();
+
+ // if there is no registered handler we just allow all page types
+ if (handler == null)
+ {
+ return types.ToList();
+ }
+
+ return (await handler.GetAllowedPageTypes(Context.Application, types, parents))?.ToList() ?? new();
+ }
+}
+
+
+public interface IPageTypeService
+{
+ ///
+ /// Get all available page types
+ ///
+ IList GetPageTypes();
+
+ ///
+ /// Get all page types which are allowed below a selected parent page
+ ///
+ Task> GetAllowedPageTypes(string pageParentId = null);
+
+ ///
+ /// Get a specific page type by alias
+ ///
+ PageType GetPageType(string pageTypeAlias);
+}
diff --git a/zero.Core/Pages/PagesStore.cs b/zero.Core/Pages/PagesStore.cs
new file mode 100644
index 00000000..bfbaaaa4
--- /dev/null
+++ b/zero.Core/Pages/PagesStore.cs
@@ -0,0 +1,52 @@
+namespace zero.Pages;
+
+public class PagesStore : TreeEntityStore, IPagesStore
+{
+ protected IPageTypeService PageTypes { get; private set; }
+
+
+ public PagesStore(IStoreContext context, IPageTypeService pageTypes) : base(context)
+ {
+ PageTypes = pageTypes;
+ }
+
+
+ ///
+ public override async Task IsAllowedAsChild(Page model, string parentId)
+ {
+ IList pageTypes = await PageTypes.GetAllowedPageTypes(parentId);
+ return pageTypes.Any(x => x.Alias == model.PageTypeAlias);
+ }
+
+
+ ///
+ public async Task Empty(string pageType, string parentId = null)
+ {
+ PageType type = PageTypes.GetPageType(pageType);
+
+ if (type == null)
+ {
+ return null;
+ }
+
+ Page model = await Empty();
+ model.PageTypeAlias = type.Alias;
+ model.ParentId = parentId;
+
+ if (!await IsAllowedAsChild(model, parentId))
+ {
+ return null; // TODO we have no way to return an error here :-/
+ }
+
+ return model;
+ }
+}
+
+
+public interface IPagesStore : ITreeEntityStore
+{
+ ///
+ /// Get new instance of an entity for a specific page type
+ ///
+ Task Empty(string pageType, string parentId = null);
+}
\ No newline at end of file
diff --git a/zero.Core/Pages/ServiceCollectionExtensions.cs b/zero.Core/Pages/ServiceCollectionExtensions.cs
index 5fc3c3ec..ae0bd934 100644
--- a/zero.Core/Pages/ServiceCollectionExtensions.cs
+++ b/zero.Core/Pages/ServiceCollectionExtensions.cs
@@ -6,6 +6,9 @@ internal static class ServiceCollectionExtensions
{
public static IServiceCollection AddZeroPages(this IServiceCollection services)
{
+ services.AddScoped();
+ services.AddScoped();
+
services.Configure(opts =>
{
RavenOptions raven = opts.For();
diff --git a/zero.Core/Persistence/ZeroStore.cs b/zero.Core/Persistence/ZeroStore.cs
index b76a3287..95881122 100644
--- a/zero.Core/Persistence/ZeroStore.cs
+++ b/zero.Core/Persistence/ZeroStore.cs
@@ -53,7 +53,7 @@ public class ZeroStore : IZeroStore
///
public IZeroDocumentSession Session(bool global, string database = null, ZeroSessionResolution resolution = ZeroSessionResolution.Reuse, SessionOptions options = null)
{
- return Session(global ? Options.Raven.Database : database, resolution, options);
+ return Session(global ? Options.For().Database : database, resolution, options);
}
}
diff --git a/zero.Core/Stores/CachableEntityStore.cs b/zero.Core/Stores/CachableEntityStore.cs
deleted file mode 100644
index 7e1d3c50..00000000
--- a/zero.Core/Stores/CachableEntityStore.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-namespace zero.Stores;
-
-public record CachableEntityStoreOptions(bool CacheIndividual, bool CacheAll);
-
-
-public abstract class CachableEntityStore : EntityStore where T : ZeroIdEntity, new()
-{
- protected CachableEntityStoreOptions CacheOptions { get; set; }
-
- protected IStoreCache Cache { get; set; }
-
-
- public CachableEntityStore(IStoreContext collectionContext, IStoreCache collectionCache) : base(collectionContext)
- {
- CacheOptions = new(CacheIndividual: true, CacheAll: false); // TODO when props update we need to update Cache.For()
- Cache = collectionCache.For(CacheOptions);
- }
-
-
- ///
- public override async Task Load(string id, string changeVector = null)
- {
- if (changeVector.IsNullOrEmpty() && Cache.TryGetValue(id, out T model))
- {
- return model;
- }
-
- return await base.Load(id, changeVector);
- }
-
-
- ///
- public override Task> Load(IEnumerable ids)
- {
- return base.Load(ids);
- }
-}
\ No newline at end of file
diff --git a/zero.Core/Stores/EntityStore.cs b/zero.Core/Stores/EntityStore.cs
index 5af99033..d78e6264 100644
--- a/zero.Core/Stores/EntityStore.cs
+++ b/zero.Core/Stores/EntityStore.cs
@@ -10,26 +10,25 @@ public abstract class EntityStore : IEntityStore where T : ZeroIdEntity, n
public Guid Guid { get; private set; } = Guid.NewGuid();
///
- public IZeroDocumentSession Session => Context.Store.Session();
-
-
- protected record EntityCollectionOptions(bool IncludeInactive);
+ public IZeroDocumentSession Session => Context.Store.Session(Config.Database);
protected IZeroContext Context { get; private set; }
- protected EntityCollectionOptions Options { get; set; }
+ protected StoreConfig Config { get; private set; } = new();
protected IInterceptors Interceptors { get; private set; }
protected IStoreOperations Operations { get; private set; }
+ protected IZeroOptions Options { get; private set; }
+
public EntityStore(IStoreContext collectionContext)
{
Operations = collectionContext.Operations;
Context = collectionContext.Context;
Interceptors = collectionContext.Interceptors;
- Options = new(IncludeInactive: true);
+ Options = collectionContext.Options;
}
@@ -93,13 +92,23 @@ public abstract class EntityStore : IEntityStore where T : ZeroIdEntity, n
///
/// Do only return the model when it is set to active or inactive entities are included with IncludeInactive()
///
- protected virtual T WhenActive(T model) => model != null && (Options.IncludeInactive || (model is ZeroEntity ? (model as ZeroEntity).IsActive : true)) ? model : default;
+ protected virtual T WhenActive(T model) => model != null && (Config.IncludeInactive || (model is ZeroEntity ? (model as ZeroEntity).IsActive : true)) ? model : default;
}
public interface IEntityStore where T : ZeroIdEntity, new()
{
+ ///
+ /// Id for this store
+ ///
+ Guid Guid { get; }
+
+ ///
+ /// Access the current document session
+ ///
+ IZeroDocumentSession Session { get; }
+
///
/// Get new instance of an entity
///
diff --git a/zero.Core/Stores/ServiceCollectionExtensions.cs b/zero.Core/Stores/ServiceCollectionExtensions.cs
index dbbafcbc..c0ddfe20 100644
--- a/zero.Core/Stores/ServiceCollectionExtensions.cs
+++ b/zero.Core/Stores/ServiceCollectionExtensions.cs
@@ -9,11 +9,6 @@ internal static class ServiceCollectionExtensions
services.AddScoped();
services.AddScoped();
services.AddSingleton();
-
- services.AddScoped();
- services.AddScoped();
- services.AddScoped();
-
return services;
}
}
\ No newline at end of file
diff --git a/zero.Core/Stores/StoreCache.cs b/zero.Core/Stores/StoreCache.cs
index 8c17307b..9221d2a8 100644
--- a/zero.Core/Stores/StoreCache.cs
+++ b/zero.Core/Stores/StoreCache.cs
@@ -24,16 +24,16 @@ public class StoreCache : IStoreCache
return false;
}
- public IStoreCache For(CachableEntityStoreOptions options)
- {
- return this;
- }
+ //public IStoreCache For(CachableEntityStoreOptions options)
+ //{
+ // return this;
+ //}
}
public interface IStoreCache
{
- IStoreCache For(CachableEntityStoreOptions options);
+ //IStoreCache For(CachableEntityStoreOptions options);
bool TryGetValue(string id, out T model);
}
\ No newline at end of file
diff --git a/zero.Core/Stores/StoreConfig.cs b/zero.Core/Stores/StoreConfig.cs
new file mode 100644
index 00000000..dad6d999
--- /dev/null
+++ b/zero.Core/Stores/StoreConfig.cs
@@ -0,0 +1,14 @@
+namespace zero.Stores;
+
+public class StoreConfig
+{
+ ///
+ /// Whether to include entities with IsActive=false in queries. Defaults to false.
+ ///
+ public bool IncludeInactive { get; set; } = false;
+
+ ///
+ /// Override the database which is resolved automatically by default (based on IZeroContext and IApplicationResolver).
+ ///
+ public string Database { get; set; }
+}
\ No newline at end of file
diff --git a/zero.Core/Stores/StoreContext.cs b/zero.Core/Stores/StoreContext.cs
index 9b1bd523..af98a680 100644
--- a/zero.Core/Stores/StoreContext.cs
+++ b/zero.Core/Stores/StoreContext.cs
@@ -12,14 +12,17 @@ public class StoreContext : IStoreContext
public IStoreOperations Operations { get; private set; }
+ public IStoreCache Cache { get; private set; }
- public StoreContext(IZeroContext context, IInterceptors interceptors, IStoreOperations operations)
+
+ public StoreContext(IZeroContext context, IInterceptors interceptors, IStoreOperations operations, IStoreCache cache)
{
Store = context.Store;
Options = context.Options;
Context = context;
Interceptors = interceptors;
Operations = operations;
+ Cache = cache;
}
}
@@ -35,4 +38,6 @@ public interface IStoreContext
IInterceptors Interceptors { get; }
IStoreOperations Operations { get; }
+
+ IStoreCache Cache { get; }
}
\ No newline at end of file
diff --git a/zero.Core/Stores/StoreOperations.Tree.cs b/zero.Core/Stores/StoreOperations.Tree.cs
new file mode 100644
index 00000000..5c19f369
--- /dev/null
+++ b/zero.Core/Stores/StoreOperations.Tree.cs
@@ -0,0 +1,197 @@
+using Raven.Client.Documents;
+using Raven.Client.Documents.Indexes;
+using Raven.Client.Documents.Linq;
+using Raven.Client.Documents.Session;
+
+namespace zero.Stores;
+
+public abstract partial class StoreOperations
+{
+ ///
+ public virtual Task IsAllowedAsChild(T model, string parentId) where T : ZeroIdEntity, IZeroTreeEntity, new()
+ {
+ return Task.FromResult(true);
+ }
+
+
+ ///
+ public async Task> LoadChildren(string parentId, int pageNumber, int pageSize, Func, IQueryable> querySelector = default) where T : ZeroIdEntity, IZeroTreeEntity, new()
+ {
+ IRavenQueryable queryable = Session.Query().Where(x => x.ParentId == parentId).Statistics(out QueryStatistics statistics);
+ querySelector ??= x => x.OrderBy(x => x.Sort);
+
+ List result = await querySelector(queryable).Paging(pageNumber, pageSize).ToListAsync();
+ return new Paged(result, statistics.TotalResults, pageNumber, pageSize);
+ }
+
+
+ ///
+ public async Task> LoadChildren(string parentId, int pageNumber, int pageSize, Func, IQueryable> querySelector = default)
+ where T : ZeroIdEntity, IZeroTreeEntity, new()
+ where TIndex : AbstractCommonApiForIndexes, new()
+ {
+ IRavenQueryable queryable = Session.Query().Where(x => x.ParentId == parentId).Statistics(out QueryStatistics statistics);
+ querySelector ??= x => x.OrderBy(x => x.Sort);
+
+ List result = await querySelector(queryable).Paging(pageNumber, pageSize).ToListAsync();
+ return new Paged(result, statistics.TotalResults, pageNumber, pageSize);
+ }
+
+
+ ///
+ public async Task>> Sort(string[] sortedIds) where T : ZeroIdEntity, IZeroTreeEntity, new()
+ {
+ Dictionary items = await Load(sortedIds);
+ uint index = 0;
+
+ // contains multiple parents, therefore fail
+ if (items.Select(x => x.Value?.ParentId).Distinct().Count() > 1)
+ {
+ return EntityResult>.Fail("@errors.treeentity.sortingmultipleparents");
+ }
+
+ foreach (var item in items)
+ {
+ item.Value.Sort = index;
+ index += 10;
+ await Update(item.Value);
+ }
+
+ return EntityResult>.Success(items.Select(x => x.Value).OrderByDescending(x => x.Sort));
+ }
+
+
+ ///
+ public async Task> Move(string id, string newParentId, Func> isParentAllowed = null) where T : ZeroIdEntity, IZeroTreeEntity, new()
+ {
+ T model = await Load(id);
+ T parent = await Load(newParentId);
+
+ if (model == null || (!newParentId.IsNullOrEmpty() && parent == null))
+ {
+ return EntityResult.Fail("@errors.idnotfound");
+ }
+
+ if (isParentAllowed != null && !await isParentAllowed(model, newParentId))
+ {
+ return EntityResult.Fail("@errors.treeentity.parentnotallowed");
+ }
+
+ model.ParentId = parent?.Id;
+
+ return await Update(model);
+ }
+
+
+ ///
+ public Task> Copy(string id, string newParentId, Func> isParentAllowed = null) where T : ZeroIdEntity, IZeroTreeEntity, new() => Copy(id, newParentId, false, isParentAllowed);
+
+
+ ///
+ public Task> CopyWithDescendants(string id, string newParentId, Func> isParentAllowed = null) where T : ZeroIdEntity, IZeroTreeEntity, new() => Copy(id, newParentId, true, isParentAllowed);
+
+
+ ///
+ /// Copies an entity (with optional descendants) to a new location
+ ///
+ protected async Task> Copy(string id, string newParentId, bool includeDescendants, Func> isParentAllowed = null) where T : ZeroIdEntity, IZeroTreeEntity, new()
+ {
+ T model = await Load(id);
+ T parent = await Load(newParentId);
+
+ if (model == null || (!newParentId.IsNullOrEmpty() && parent == null))
+ {
+ return EntityResult.Fail("@errors.idnotfound");
+ }
+
+ string baseId = model.Id;
+
+ // update new page properties
+ model.Id = null;
+ model.ParentId = parent?.Id;
+
+ if (model is ZeroEntity zeroEntity)
+ {
+ zeroEntity.IsActive = false;
+ zeroEntity.CreatedDate = DateTime.Now;
+ }
+
+ // check if new parent is allowed
+ if (isParentAllowed != null && !await isParentAllowed(model, newParentId))
+ {
+ return EntityResult.Fail("@errors.treeentity.parentnotallowed");
+ }
+
+ // recursive function to store descendants
+ async Task AddChildren(string oldParentId, string newParentId)
+ {
+ List children = await Session.Query().Where(x => x.ParentId == oldParentId).ToListAsync();
+
+ foreach (T child in children)
+ {
+ T clonedChild = ObjectCopycat.Clone(child);
+ clonedChild.Id = null;
+ clonedChild.ParentId = newParentId;
+ if (clonedChild is ZeroEntity zeroEntity)
+ {
+ zeroEntity.IsActive = false;
+ zeroEntity.CreatedDate = DateTime.Now;
+ }
+
+ await Create(clonedChild);
+ await AddChildren(child.Id, clonedChild.Id);
+ }
+ }
+
+ if (includeDescendants)
+ {
+ await AddChildren(baseId, model.Id);
+ }
+
+ return EntityResult.Success(model);
+ }
+
+
+ ///
+ public async Task> DeleteWithDescendants(T model) where T : ZeroIdEntity, IZeroTreeEntity, new()
+ {
+ List pages = await GetDescendantsAndSelf(model);
+
+ if (pages.Count < 1)
+ {
+ return EntityResult.Fail("@errors.ondelete.idnotfound");
+ }
+
+ await Delete(pages.ToArray());
+ return EntityResult.Success(pages.Select(x => x.Id).ToArray());
+ }
+
+
+ ///
+ public async Task> DeleteWithDescendants(string id) where T : ZeroIdEntity, IZeroTreeEntity, new() => await DeleteWithDescendants(await Load(id));
+
+
+ ///
+ /// Get an entity with all its descendants
+ ///
+ async Task> GetDescendantsAndSelf(T model) where T : ZeroIdEntity, IZeroTreeEntity, new()
+ {
+ List items = new() { model };
+
+ async Task AddChildren(T parent)
+ {
+ string parentId = parent.Id;
+ List children = await Session.Query().Where(x => x.ParentId == parentId).ToListAsync();
+ items.AddRange(children);
+
+ foreach (T child in children)
+ {
+ await AddChildren(child);
+ }
+ }
+
+ await AddChildren(model);
+
+ return items;
+ }
+}
\ No newline at end of file
diff --git a/zero.Core/Stores/StoreOperations.cs b/zero.Core/Stores/StoreOperations.cs
index 7f84d689..61bad221 100644
--- a/zero.Core/Stores/StoreOperations.cs
+++ b/zero.Core/Stores/StoreOperations.cs
@@ -202,4 +202,44 @@ public interface IStoreOperations
/// Deletes entities by Id
///
Task Delete(IEnumerable ids) where T : ZeroIdEntity, new();
+
+ ///
+ /// Loads all children for an entity
+ ///
+ Task> LoadChildren(string parentId, int pageNumber, int pageSize, Func, IQueryable> querySelector = default) where T : ZeroIdEntity, IZeroTreeEntity, new();
+
+ ///
+ /// Get descendants by query (by using the specified index)
+ ///
+ Task> LoadChildren(string parentId, int pageNumber, int pageSize, Func, IQueryable> querySelector = default) where T : ZeroIdEntity, IZeroTreeEntity, new() where TIndex : AbstractCommonApiForIndexes, new();
+
+ ///
+ /// Update sorting of entities on a specific level
+ ///
+ Task>> Sort(string[] sortedIds) where T : ZeroIdEntity, IZeroTreeEntity, new();
+
+ ///
+ /// Move an entity to a new parent
+ ///
+ Task> Move(string id, string newParentId, Func> isParentAllowed = null) where T : ZeroIdEntity, IZeroTreeEntity, new();
+
+ ///
+ /// Copies an entity to a new location
+ ///
+ Task> Copy(string id, string newParentId, Func> isParentAllowed = null) where T : ZeroIdEntity, IZeroTreeEntity, new();
+
+ ///
+ /// Copies an entity with descendants to a new location
+ ///
+ Task> CopyWithDescendants(string id, string newParentId, Func> isParentAllowed = null) where T : ZeroIdEntity, IZeroTreeEntity, new();
+
+ ///
+ /// Deletes an entity with all descendents
+ ///
+ Task> DeleteWithDescendants(T model) where T : ZeroIdEntity, IZeroTreeEntity, new();
+
+ ///
+ /// Deletes an entity by Id with all descendents
+ ///
+ Task> DeleteWithDescendants(string id) where T : ZeroIdEntity, IZeroTreeEntity, new();
}
\ No newline at end of file
diff --git a/zero.Core/Stores/TreeEntityStore.cs b/zero.Core/Stores/TreeEntityStore.cs
new file mode 100644
index 00000000..e49d90cf
--- /dev/null
+++ b/zero.Core/Stores/TreeEntityStore.cs
@@ -0,0 +1,89 @@
+using Raven.Client.Documents.Indexes;
+using Raven.Client.Documents.Linq;
+
+namespace zero.Stores;
+
+public abstract class TreeEntityStore : EntityStore, ITreeEntityStore where T : ZeroIdEntity, IZeroTreeEntity, new()
+{
+ public TreeEntityStore(IStoreContext collectionContext) : base(collectionContext) { }
+
+ ///
+ public virtual Task IsAllowedAsChild(T model, string parentId) => Task.FromResult(true);
+
+ ///
+ public virtual Task> Copy(string id, string newParentId) => Operations.Copy(id, newParentId, async (model, parentId) => await IsAllowedAsChild(model, parentId));
+
+ ///
+ public virtual Task> CopyWithDescendants(string id, string newParentId) => Operations.Copy(id, newParentId, async (model, parentId) => await IsAllowedAsChild(model, parentId));
+
+ ///
+ public virtual Task> Move(string id, string newParentId) => Operations.Copy(id, newParentId, async (model, parentId) => await IsAllowedAsChild(model, parentId));
+
+ ///
+ public virtual Task>> Sort(string[] sortedIds) => Operations.Sort(sortedIds);
+
+ ///
+ public virtual Task> DeleteWithDescendants(T model) => Operations.DeleteWithDescendants(model);
+
+ ///
+ public virtual Task> DeleteWithDescendants(string id) => Operations.DeleteWithDescendants(id);
+
+ ///
+ public virtual Task> LoadChildren(string parentId, int pageNumber, int pageSize, Func, IQueryable> querySelector = null)
+ => Operations.LoadChildren(parentId, pageNumber, pageSize, querySelector);
+
+ ///
+ public virtual Task> LoadChildren(string parentId, int pageNumber, int pageSize, Func, IQueryable> querySelector = null) where TIndex : AbstractCommonApiForIndexes, new()
+ => Operations.LoadChildren(parentId, pageNumber, pageSize, querySelector);
+}
+
+
+
+public interface ITreeEntityStore : IEntityStore where T : ZeroIdEntity, IZeroTreeEntity, new()
+{
+ ///
+ /// Determines whether a model is allowed as a child for a new parent.
+ /// This is primarily used by copy/move operations
+ ///
+ Task IsAllowedAsChild(T model, string parentId);
+
+ ///
+ /// Loads all children for an entity
+ ///
+ Task> LoadChildren(string parentId, int pageNumber, int pageSize, Func, IQueryable> querySelector = default);
+
+ ///
+ /// Get descendants by query (by using the specified index)
+ ///
+ Task> LoadChildren(string parentId, int pageNumber, int pageSize, Func, IQueryable> querySelector = default) where TIndex : AbstractCommonApiForIndexes, new();
+
+ ///
+ /// Update sorting of entities on a specific level
+ ///
+ Task>> Sort(string[] sortedIds);
+
+ ///
+ /// Move an entity to a new parent
+ ///
+ Task> Move(string id, string newParentId);
+
+ ///
+ /// Copies an entity to a new location
+ ///
+ Task> Copy(string id, string newParentId);
+
+ ///
+ /// Copies an entity with descendants to a new location
+ ///
+ Task> CopyWithDescendants(string id, string newParentId);
+
+ ///
+ /// Deletes an entity with all descendents
+ ///
+ Task> DeleteWithDescendants(T model);
+
+ ///
+ /// Deletes an entity by Id with all descendents
+ ///
+ Task> DeleteWithDescendants(string id);
+}
\ No newline at end of file
diff --git a/zero.Core/Unimplemented/PreviewApi.cs b/zero.Core/Unimplemented/PreviewApi.cs
new file mode 100644
index 00000000..7363049e
--- /dev/null
+++ b/zero.Core/Unimplemented/PreviewApi.cs
@@ -0,0 +1,75 @@
+//using System;
+//using System.Threading.Tasks;
+//using zero.Core.Collections;
+//using zero.Core.Entities;
+//using zero.Core.Extensions;
+//using Rc = Raven.Client;
+
+//namespace zero.Core.Api
+//{
+// public class PreviewApi : BackofficeApi, IPreviewApi
+// {
+// Preview Blueprint;
+
+// public PreviewApi(IStoreContext store, Preview blueprint) : base(store)
+// {
+// Blueprint = blueprint;
+// }
+
+
+// ///
+// public async Task> Add(TEntity model) where TEntity : ZeroEntity
+// {
+// return await Update(null, model);
+// }
+
+
+// ///
+// public async Task> Update(string id, TEntity model) where TEntity : ZeroEntity
+// {
+// Preview entity = id == null ? await GetById(id) : Blueprint.Clone();
+// entity.Content = model;
+// entity.OriginalId = model.Id;
+// entity.Name = model.Name;
+
+// return await SaveModel(entity, meta: x =>
+// {
+// x[Rc.Constants.Documents.Metadata.Expires] = GetExpiry(model);
+// });
+// }
+
+
+// ///
+// public async Task GetById(string id)
+// {
+// return await GetById(id);
+// }
+
+
+// ///
+// /// Get preview expiration for a document
+// ///
+// DateTime GetExpiry(ZeroEntity model)
+// {
+// return DateTime.Now.AddHours(1);
+// }
+// }
+
+// public interface IPreviewApi
+// {
+// ///
+// /// Adds an entity to the preview collection. This will generate a preview with an id which can be used for the preview view.
+// ///
+// Task> Add(TEntity model) where TEntity : ZeroEntity;
+
+// ///
+// /// Updates an entity in the preview collection. This will generate a preview with an id which can be used for the preview view.
+// ///
+// Task> Update(string id, TEntity model) where TEntity : ZeroEntity;
+
+// ///
+// /// Get preview entity by Id
+// ///
+// Task GetById(string id);
+// }
+//}
diff --git a/zero.Core/Unimplemented/RecycleBinApi.cs b/zero.Core/Unimplemented/RecycleBinApi.cs
new file mode 100644
index 00000000..11afbded
--- /dev/null
+++ b/zero.Core/Unimplemented/RecycleBinApi.cs
@@ -0,0 +1,258 @@
+//using Raven.Client.Documents;
+//using Raven.Client.Documents.Linq;
+//using Raven.Client.Documents.Session;
+//using System.Collections.Generic;
+//using System.Threading.Tasks;
+//using zero.Core.Collections;
+//using zero.Core.Database.Indexes;
+//using zero.Core.Entities;
+//using zero.Core.Extensions;
+//using zero.Core.Utils;
+
+//namespace zero.Core.Api
+//{
+// public class RecycleBinApi : BackofficeApi, IRecycleBinApi
+// {
+// RecycledEntity Blueprint;
+
+// public RecycleBinApi(IStoreContext store, RecycledEntity blueprint) : base(store)
+// {
+// Blueprint = blueprint;
+// }
+
+
+// ///
+// public async Task> Add(TEntity model, string group = null, string operationId = null) where TEntity : ZeroEntity
+// {
+// RecycledEntity entity = Blueprint.Clone();
+// entity.Group = group;
+// entity.Content = model;
+// entity.OriginalId = model.Id;
+// entity.OperationId = operationId;
+// entity.Name = model.Name;
+
+// return await SaveModel(entity);
+// }
+
+
+// ///
+// public async Task>> Add(IEnumerable models, string group = null) where TEntity : ZeroEntity
+// {
+// IList results = new List();
+// string operationId = IdGenerator.Create();
+
+// foreach (TEntity model in models)
+// {
+// EntityResult result = await Add(model, group, operationId);
+
+// if (!result.IsSuccess)
+// {
+// return EntityResult>.Fail(result.Errors);
+// }
+
+// results.Add(result.Model);
+// }
+
+// return EntityResult>.Success(results);
+// }
+
+
+// ///
+// public async Task GetById(string id)
+// {
+// return await GetById(id);
+// }
+
+
+// ///
+// public async Task> GetByQuery(RecycleBinListQuery query)
+// {
+// query.SearchSelector = x => x.Name;
+
+// return await Session.Query()
+// .WhereIf(x => x.Group == query.Group, !query.Group.IsNullOrWhiteSpace())
+// .WhereIf(x => x.OperationId == query.OperationId, !query.OperationId.IsNullOrWhiteSpace())
+// .ToQueriedListAsync(query);
+// }
+
+
+// ///
+// public async Task> GetByOperation(string operationId)
+// {
+// return await Session.Query()
+// .Where(x => x.OperationId == operationId)
+// .ToListAsync();
+// }
+
+
+// ///
+// /// Get affected entity count from a specific operation
+// ///
+// public async Task GetCountByOperation(string operationId)
+// {
+// return await Session.Query()
+// .Where(x => x.OperationId == operationId)
+// .CountAsync();
+// }
+
+
+// ///
+// public async Task> Delete(string id)
+// {
+// return await DeleteById(id);
+// }
+
+
+// ///
+// public async Task> DeleteAll()
+// {
+// // TODO make Purge operations app-aware
+// return await Purge();
+// }
+
+
+// ///
+// public async Task> DeleteByOperation(string operationId)
+// {
+// return await Purge("where c.OperationId = $id", new Raven.Client.Parameters() { { "id", operationId } });
+// }
+
+
+// ///
+// public async Task> DeleteByGroup(string group)
+// {
+// return await Purge("where c.Group = $group", new Raven.Client.Parameters() { { "group", group } });
+// }
+// }
+
+// public interface IRecycleBinApi
+// {
+// ///
+// /// Adds an entity to the recycle bin. This operation will not remove this entity from it's own collection!
+// ///
+// Task> Add(TEntity model, string group = null, string operationId = null) where TEntity : ZeroEntity;
+
+// ///
+// /// Adds the specified entities to the recycle bin. This operation will not remove entities from their own collection!
+// ///
+// Task>> Add(IEnumerable models, string group = null) where TEntity : ZeroEntity;
+
+// ///
+// /// Get recycled entity by Id
+// ///
+// Task GetById(string id);
+
+// ///
+// /// Get all recycled items
+// ///
+// Task> GetByQuery(RecycleBinListQuery query);
+
+// ///
+// /// Get affected entities from a specific operation
+// ///
+// Task> GetByOperation(string operationId);
+
+// ///
+// /// Get affected entity count from a specific operation
+// ///
+// Task GetCountByOperation(string operationId);
+
+// //
+// /// Deletes a recycled entity by Id
+// ///
+// Task> Delete(string id);
+
+// ///
+// /// Purges the recycle bin
+// ///
+// Task> DeleteAll();
+
+// ///
+// /// Deletes all recycled items from a specific operation
+// ///
+// Task> DeleteByOperation(string operationId);
+
+// ///
+// /// Deletes all recycled items from a specific group
+// ///
+// Task> DeleteByGroup(string group);
+// }
+//}
+
+
+/*
+ * RESTORE FOR PAGES
+ *
+ *
+ *
+
+///
+ /// Restores a page from the recycle bin
+ ///
+ Task> Restore(string id, bool includeDescendants = false);
+
+
+ ///
+ public async Task> Restore(string id, bool includeDescendants = false)
+ {
+ EntityResult result = new EntityResult();
+ RecycledEntity recycledEntity = await RecycleBinApi.GetById(id);
+ List entities = new List() { recycledEntity };
+
+ if (recycledEntity == null)
+ {
+ return EntityResult.Fail(); // TODO correct error message
+ }
+
+ // get descendants from the operation
+ if (includeDescendants && !recycledEntity.OperationId.IsNullOrEmpty())
+ {
+ entities = (await RecycleBinApi.GetByOperation(recycledEntity.OperationId)).ToList();
+ }
+
+ // fill ids
+ string[] ids = entities.Select(x => x.OriginalId).ToArray();
+
+ // check if parents are available
+ string[] parentIds = entities.Select(x => x.Content as Page).Where(x => x != null).Select(x => x.ParentId).ToArray();
+ parentIds = (await Load(parentIds)).Where(x => x.Value != null).Select(x => x.Value.Id).ToArray();
+
+ // validate and restore all items
+ foreach (RecycledEntity entity in entities)
+ {
+ // check if it contains page data
+ if (entity.Group != RECYCLE_BIN_GROUP || !(entity.Content is Page))
+ {
+ //result.AddError("Cannot parse recycled entity as an Page in group \"" + RECYCLE_BIN_GROUP + "\""); // TODO correct error message
+ continue;
+ }
+
+ // get page
+ Page page = entity.Content as Page;
+ page.IsActive = false;
+
+ // validate app and parent
+ if (!page.ParentId.IsNullOrEmpty() && !ids.Contains(page.ParentId) && !parentIds.Contains(page.ParentId))
+ {
+ // TODO correct error message
+ continue;
+ }
+
+ // restore to pages
+ EntityResult saveResult = await Create(page);
+ }
+
+ // delete affected entities from recycle bin
+ if (!recycledEntity.OperationId.IsNullOrEmpty())
+ {
+ await RecycleBinApi.DeleteByOperation(recycledEntity.OperationId);
+ }
+
+ // set result
+ result.Model = ids;
+ result.IsSuccess = true;
+
+ return result;
+ }
+
+ */
\ No newline at end of file
diff --git a/zero.Core/Unimplemented/RecycleBinListQuery.cs b/zero.Core/Unimplemented/RecycleBinListQuery.cs
new file mode 100644
index 00000000..c1ee3f7d
--- /dev/null
+++ b/zero.Core/Unimplemented/RecycleBinListQuery.cs
@@ -0,0 +1,9 @@
+//namespace zero.Core.Entities
+//{
+// public class RecycleBinListQuery : ListQuery
+// {
+// public string Group { get; set; }
+
+// public string OperationId { get; set; }
+// }
+//}
diff --git a/zero.Core/Unimplemented/RecycledEntity.cs b/zero.Core/Unimplemented/RecycledEntity.cs
new file mode 100644
index 00000000..a51befcc
--- /dev/null
+++ b/zero.Core/Unimplemented/RecycledEntity.cs
@@ -0,0 +1,28 @@
+//using zero.Core.Attributes;
+
+//namespace zero.Core.Entities
+//{
+// [Collection("RecycleBin")]
+// public class RecycledEntity : ZeroEntity
+// {
+// ///
+// /// Id of the recycled entity
+// ///
+// public string OriginalId { get; set; }
+
+// ///
+// /// Group recycled entities together which have been recycled in a single operation
+// ///
+// public string OperationId { get; set; }
+
+// ///
+// /// Contains the entity content
+// ///
+// public ZeroEntity Content { get; set; }
+
+// ///
+// /// Recycled entities can be grouped together (e.g. pages, media, ...)
+// ///
+// public string Group { get; set; }
+// }
+//}
diff --git a/zero.Core/Unimplemented/RevisionsApi.cs b/zero.Core/Unimplemented/RevisionsApi.cs
new file mode 100644
index 00000000..bc50f876
--- /dev/null
+++ b/zero.Core/Unimplemented/RevisionsApi.cs
@@ -0,0 +1,102 @@
+//using Raven.Client.Documents.Session;
+//using System.Collections.Generic;
+//using System.Linq;
+//using System.Threading.Tasks;
+//using zero.Core.Entities;
+//using zero.Core.Extensions;
+
+//namespace zero.Core.Api
+//{
+// public class RevisionsApi : IRevisionsApi
+// {
+// protected IAsyncDocumentSession Session { get; set; }
+
+// public RevisionsApi(IAsyncDocumentSession session)
+// {
+// Session = session;
+// }
+
+
+// ///
+// /// Get revision list for an entity
+// ///
+// public async Task>> GetPagedWithModel(string id, int pageNumber = 1, int pageSize = 10) where T : ZeroEntity
+// {
+// // get paged revisions
+// List items = await Session.Advanced.Revisions.GetForAsync(id, (pageNumber - 1) * pageSize, pageSize);
+// long totalResults = await Session.Advanced.Revisions.GetCountForAsync(id);
+
+// // add creation for last page
+// //if (!items.Any() && pageNumber >= (int)Math.Ceiling((decimal)totalResults / pageSize))
+// //{
+// // T currentRevision = await Session.LoadAsync(id);
+// // if (currentRevision != null)
+// // {
+// // items.Add(currentRevision);
+// // }
+// //}
+
+// List> revisions = new();
+
+// // load affected users as the revisions could have been edited by other users too
+// string[] userIds = items.Select(x => x.LastModifiedById).Distinct().ToArray();
+// Dictionary users = await Session.LoadAsync(userIds);
+
+// // create revision objects
+// foreach (T item in items)
+// {
+// Revision revision = new()
+// {
+// Model = item,
+// ModelId = item.Id,
+// ChangeVector = Session.Advanced.GetChangeVectorFor(item),
+// Date = item.LastModifiedDate
+// };
+
+// if (!item.LastModifiedById.IsNullOrEmpty() && users.TryGetValue(item.LastModifiedById, out ZeroUser user) && user != null)
+// {
+// revision.User = new RevisionUser()
+// {
+// AvatarId = user.AvatarId,
+// Id = user.Id,
+// Name = user.Name
+// };
+// }
+
+// revisions.Add(revision);
+// }
+
+// return new Paged>(revisions, totalResults, pageNumber, pageSize);
+// }
+
+
+// ///
+// /// Get revision list for an entity
+// ///
+// public async Task> GetPaged(string id, int pageNumber = 1, int pageSize = 10) where T : ZeroEntity
+// {
+// Paged> items = await GetPagedWithModel(id, pageNumber, pageSize);
+// return items.MapTo(model => new Revision()
+// {
+// ChangeVector = model.ChangeVector,
+// Date = model.Date,
+// ModelId = model.ModelId,
+// User = model.User
+// });
+// }
+// }
+
+
+// public interface IRevisionsApi
+// {
+// ///
+// /// Get revision list including models for an entity
+// ///
+// Task>> GetPagedWithModel(string id, int pageNumber = 1, int pageSize = 10) where T : ZeroEntity;
+
+// ///
+// /// Get revision list for an entity
+// ///
+// Task> GetPaged(string id, int pageNumber = 1, int pageSize = 10) where T : ZeroEntity;
+// }
+//}
diff --git a/zero.Core/Unimplemented/SetupApi.cs b/zero.Core/Unimplemented/SetupApi.cs
new file mode 100644
index 00000000..0eefe3d1
--- /dev/null
+++ b/zero.Core/Unimplemented/SetupApi.cs
@@ -0,0 +1,339 @@
+//using FluentValidation.Results;
+//using Microsoft.AspNetCore.Identity;
+//using Newtonsoft.Json;
+//using Raven.Client.Documents;
+//using Raven.Client.Documents.Session;
+//using System.IO;
+//using System.Security.Cryptography;
+//using zero.Setup;
+
+//namespace zero.Core.Api
+//{
+// public class SetupApi : ISetupApi
+// {
+// protected IZeroOptions Options { get; private set; }
+
+// protected IPasswordHasher PasswordHasher { get; private set; }
+
+// protected IZeroDocumentConventionsBuilder ConventionsBuilder { get; private set; }
+
+
+// public SetupApi(IZeroOptions options, IPasswordHasher passwordHasher, IZeroDocumentConventionsBuilder conventionsBuilder)
+// {
+// Options = options;
+// PasswordHasher = passwordHasher;
+// ConventionsBuilder = conventionsBuilder;
+// }
+
+
+// public async Task> Install(SetupModel model)
+// {
+// ValidationResult validation = await new SetupModelValidator().ValidateAsync(model);
+
+// if (!validation.IsValid)
+// {
+// return EntityResult.Fail(validation);
+// }
+
+// DocumentStore raven = null;
+
+// try
+// {
+// // test read & write permissions on folders
+// // TODO
+
+// // create temporary instance of database
+// raven = new DocumentStore()
+// {
+// Urls = model.Database.Url.Split(','),
+// Database = model.Database.Name
+// };
+
+// ConventionsBuilder.Run(raven.Conventions);
+// raven.Initialize();
+
+// // create application
+// Application app = Prepare(new Application()
+// {
+// Id = "app." + Safenames.Alias(model.AppName),
+// CreatedDate = DateTimeOffset.Now,
+// IsActive = true,
+// Name = model.AppName,
+// Alias = Safenames.Alias(model.AppName),
+// Database = model.Database.Name
+// });
+
+// // create user
+// ZeroUser user = Prepare(new ZeroUser()
+// {
+// IsSuper = true,
+// CreatedDate = DateTimeOffset.Now,
+// Email = model.User.Email,
+// Username = model.User.Email,
+// Name = model.User.Name,
+// IsActive = true,
+// LanguageId = Options.DefaultLanguage,
+// Alias = Safenames.Alias(model.User.Name),
+// IsEmailConfirmed = true,
+// SecurityStamp = NewSecurityStamp()
+// });
+
+// user.PasswordHash = PasswordHasher.HashPassword(user, model.User.Password);
+
+// // create default language
+// Language language = Prepare(new Language() // TODO get default language selection from setup UI
+// {
+// Name = "English",
+// Alias = Safenames.Alias("English"),
+// CreatedDate = DateTimeOffset.Now,
+// Code = "en-US",
+// IsActive = true,
+// IsDefault = true
+// });
+
+// using IAsyncDocumentSession session = raven.OpenAsyncSession();
+
+// await session.StoreAsync(user);
+
+// // user creation failed
+// //if (!result.Succeeded)
+// //{
+// // EntityResult entityResult = EntityResult.Fail();
+
+// // foreach (IdentityError error in result.Errors)
+// // {
+// // entityResult.AddError(error.Code, error.Description);
+// // }
+
+// // return entityResult;
+// //}
+
+
+// await session.StoreAsync(app);
+
+// // save default user roles
+// IList roles = GetRoles(model);
+
+// foreach (ZeroUserRole role in roles)
+// {
+// await session.StoreAsync(role);
+// }
+
+// // add admin role to super user
+// // set app-id for user and store it
+// user.AppId = session.Advanced.GetDocumentId(app);
+// user.CurrentAppId = user.AppId;
+// user.RoleIds.Add(roles.First(role => role.Name == "Standard").Id);
+// user.RoleIds.Add(roles.First(role => role.Name == "Administrator").Id);
+// await session.StoreAsync(user);
+
+// // create language
+// await session.StoreAsync(language);
+
+// // set countries
+// using (Raven.Client.Documents.BulkInsert.BulkInsertOperation bulkInsert = raven.BulkInsert())
+// {
+// foreach (Country country in GetCountries(model, language))
+// {
+// await bulkInsert.StoreAsync(country);
+// }
+// }
+
+// // update settings file. if this fails the changes won't be stored
+// UpdateSettingsFile(model);
+
+// await session.SaveChangesAsync();
+// }
+// catch (Exception)
+// {
+// // TODO revert
+// throw;
+// }
+// finally
+// {
+// raven?.Dispose();
+// }
+
+// return EntityResult.Success(model);
+// }
+
+
+// ///
+// /// Updates the settings file with the new data (database connection and version)
+// ///
+// void UpdateSettingsFile(SetupModel model)
+// {
+// // TODO should we write this into appSettings.json now?
+// // or let the user do it in the code editor?
+
+// //var filePath = Path.Combine(model.ContentRootPath, "zeroSettings.json");
+// //string json = File.ReadAllText(filePath);
+
+// //ZeroConfiguration config = JsonConvert.DeserializeObject(json);
+
+// //config.Raven = new RavenOptions()
+// //{
+// // Database = model.Database.Name,
+// // Url = model.Database.Url
+// //};
+
+// //config.ZeroVersion = System.Reflection.Assembly.GetEntryAssembly().GetName().Version.ToString();
+
+// //json = JsonConvert.SerializeObject(config, Formatting.Indented);
+
+// //File.WriteAllText(filePath, json);
+// }
+
+
+// ///
+// /// Get countries for supported backoffice languages
+// ///
+// IList GetCountries(SetupModel model, Language defaultLanguage)
+// {
+// List countries = new List();
+
+// //string[] isoCodes = Config.SupportedLanguages;
+// string[] isoCodes = new string[1] { "en-US" };
+
+// foreach (string languageISO in isoCodes)
+// {
+// // country list from: https://github.com/umpirsky/country-list/tree/master/data
+// var filePath = Path.Combine(model.ContentRootPath, "Resources", "Countries", "countries." + languageISO.ToLowerInvariant() + ".json");
+// string json = File.ReadAllText(filePath);
+
+
+// countries.AddRange(JsonConvert.DeserializeObject>(json).Select(country => Prepare(new Country()
+// {
+// CreatedDate = DateTimeOffset.Now,
+// IsActive = true,
+// //AppId = Constants.Database.SharedAppId, // TODO appx fix
+// Alias = Safenames.Alias(country.Value),
+// //LanguageId = defaultLanguage.Id,
+// Code = country.Key.ToLowerInvariant(),
+// Name = country.Value
+// })).ToList());
+// }
+
+// return countries;
+// }
+
+
+// ///
+// /// Create default roles
+// ///
+// IList GetRoles(SetupModel model)
+// {
+// string type = Constants.Auth.Claims.Permission;
+
+// ZeroUserRole adminRole = Prepare(new ZeroUserRole()
+// {
+// Name = "Administrator",
+// Alias = Safenames.Alias("Administrator"),
+// Sort = 0,
+// Icon = "fth-award",
+// CreatedDate = DateTimeOffset.Now,
+// IsActive = true,
+// Claims = new List()
+// {
+// //new UserClaim(type, Permissions.Applications, PermissionsValue.Write),
+// new UserClaim(type, Permissions.Sections.Dashboard, PermissionsValue.True),
+// new UserClaim(type, Permissions.Sections.Spaces, PermissionsValue.True),
+// new UserClaim(type, Permissions.Sections.Pages, PermissionsValue.True),
+// new UserClaim(type, Permissions.Sections.Media, PermissionsValue.True),
+// new UserClaim(type, Permissions.Sections.Settings, PermissionsValue.True),
+// new UserClaim(type, Permissions.Settings.Applications, PermissionsValue.None),
+// new UserClaim(type, Permissions.Settings.Countries, PermissionsValue.Update),
+// new UserClaim(type, Permissions.Settings.Logging, PermissionsValue.Update),
+// new UserClaim(type, Permissions.Settings.Translations, PermissionsValue.Update),
+// new UserClaim(type, Permissions.Settings.Updates, PermissionsValue.Update),
+// new UserClaim(type, Permissions.Settings.Users, PermissionsValue.Update),
+// },
+// });
+
+// ZeroUserRole editorRole = Prepare(new ZeroUserRole()
+// {
+// Name = "Editor",
+// Alias = Safenames.Alias("Editor"),
+// Sort = 1,
+// Icon = "fth-feather",
+// CreatedDate = DateTimeOffset.Now,
+// IsActive = true,
+// Claims = new List()
+// {
+// new UserClaim(type, Permissions.Sections.Dashboard, PermissionsValue.True),
+// new UserClaim(type, Permissions.Sections.Spaces, PermissionsValue.True),
+// new UserClaim(type, Permissions.Sections.Pages, PermissionsValue.True),
+// new UserClaim(type, Permissions.Sections.Media, PermissionsValue.True),
+// new UserClaim(type, Permissions.Sections.Settings, PermissionsValue.True),
+// new UserClaim(type, Permissions.Settings.Translations, PermissionsValue.True)
+// }
+// });
+
+// ZeroUserRole defaultRole = Prepare(new ZeroUserRole()
+// {
+// Name = "Standard",
+// Alias = Safenames.Alias("Standard"),
+// Sort = 2,
+// Icon = "fth-users",
+// CreatedDate = DateTimeOffset.Now,
+// IsActive = true,
+// Claims = new List()
+// {
+// new UserClaim(type, Permissions.Sections.Dashboard, PermissionsValue.True)
+// }
+// });
+
+// return new List() { adminRole, editorRole, defaultRole };
+// }
+
+
+// T Prepare(T model, string languageId = null) where T : ZeroIdEntity
+// {
+// ZeroEntity zeroEntity = model as ZeroEntity;
+
+// // set default properties
+// if (zeroEntity != null && zeroEntity.CreatedDate == default)
+// {
+// zeroEntity.CreatedDate = DateTimeOffset.Now;
+// }
+// if (zeroEntity != null && zeroEntity.CreatedById == default)
+// {
+// zeroEntity.CreatedById = Constants.Auth.SystemUser;
+// }
+// if (zeroEntity != null && zeroEntity.LanguageId == default)
+// {
+// zeroEntity.LanguageId = languageId;
+// }
+
+// // update name alias and last modified
+// if (zeroEntity != null)
+// {
+// zeroEntity.Alias = Safenames.Alias(zeroEntity.Name);
+// zeroEntity.LastModifiedById = Constants.Auth.SystemUser;
+// zeroEntity.LastModifiedDate = DateTimeOffset.Now;
+// zeroEntity.Hash ??= IdGenerator.Create();
+// zeroEntity.IsActive = true;
+// }
+
+// return model;
+// }
+
+
+// ///
+// /// Creates a new security stamp
+// ///
+// string NewSecurityStamp()
+// {
+// byte[] bytes = new byte[20];
+// RandomNumberGenerator.Fill(bytes);
+// return Base32.ToBase32(bytes);
+// }
+// }
+
+
+
+// public interface ISetupApi
+// {
+// Task> Install(SetupModel model);
+// }
+//}
diff --git a/zero.Core/Utils/ObjectCopycat.cs b/zero.Core/Utils/ObjectCopycat.cs
index a29274d1..a3b6b15a 100644
--- a/zero.Core/Utils/ObjectCopycat.cs
+++ b/zero.Core/Utils/ObjectCopycat.cs
@@ -1,4 +1,5 @@
-using System.Collections.Concurrent;
+using Newtonsoft.Json;
+using System.Collections.Concurrent;
using System.Reflection;
namespace zero.Utils;
@@ -12,6 +13,12 @@ public class ObjectCopycat
static ConcurrentDictionary> PublicPropertiesPerType { get; set; } = new();
+ public static T Clone(T obj)
+ {
+ Type type = obj.GetType();
+ return (T)JsonConvert.DeserializeObject(JsonConvert.SerializeObject(obj), type);
+ }
+
public static T CopyProperties(T source, T target, params string[] exceptions)
{
diff --git a/zero.Core/_legacy/Api/ApplicationsApi.cs b/zero.Core/_legacy/Api/ApplicationsApi.cs
deleted file mode 100644
index 5608a0a1..00000000
--- a/zero.Core/_legacy/Api/ApplicationsApi.cs
+++ /dev/null
@@ -1,91 +0,0 @@
-using FluentValidation;
-using Raven.Client.Documents;
-using Raven.Client.Documents.Session;
-using System.Collections.Generic;
-using System.Threading.Tasks;
-using zero.Core.Collections;
-using zero.Core.Entities;
-using zero.Core.Extensions;
-
-namespace zero.Core.Api
-{
- public class ApplicationsApi : BackofficeApi, IApplicationsApi
- {
- IValidator Validator;
-
-
- public ApplicationsApi(IStoreContext store, IValidator validator) : base(store, isCoreDatabase: true)
- {
- Validator = validator;
- }
-
-
- ///
- public async Task GetById(string id)
- {
- return await GetById(id);
- }
-
-
- ///
- public async Task> GetAll()
- {
- return await Session
- .Query()
- .OrderByDescending(x => x.CreatedDate)
- .ToListAsync();
- }
-
-
- ///
- public async Task> GetByQuery(ListQuery query)
- {
- query.SearchFor(entity => entity.Name);
-
- return await Session.Query().ToQueriedListAsync(query);
- }
-
-
- ///
- public async Task> Save(Application model)
- {
- return await SaveModel(model, Validator);
- }
-
-
- ///
- public async Task> Delete(string id)
- {
- return await DeleteById(id);
- }
- }
-
-
- public interface IApplicationsApi
- {
- ///
- /// Get application by Id
- ///
- Task GetById(string id);
-
- ///
- /// Get all available zero applications
- ///
- Task> GetAll();
-
- ///
- /// Get all available applications (with query)
- ///
- Task> GetByQuery(ListQuery query);
-
- ///
- /// Creates or updates a application
- ///
- Task> Save(Application model);
-
- ///
- /// Deletes a application by Id
- ///
- Task> Delete(string id);
- }
-}
diff --git a/zero.Core/_legacy/Api/BackofficeApi.cs b/zero.Core/_legacy/Api/BackofficeApi.cs
deleted file mode 100644
index 592dcecd..00000000
--- a/zero.Core/_legacy/Api/BackofficeApi.cs
+++ /dev/null
@@ -1,242 +0,0 @@
-using FluentValidation;
-using FluentValidation.Results;
-using Raven.Client;
-using Raven.Client.Documents.Session;
-using System;
-using System.Collections.Generic;
-using System.Security.Claims;
-using System.Threading.Tasks;
-using zero.Core.Attributes;
-using zero.Core.Collections;
-using zero.Core.Database;
-using zero.Core.Entities;
-using zero.Core.Extensions;
-using zero.Core.Utils;
-
-namespace zero.Core.Api
-{
- public abstract class BackofficeApi : IBackofficeApi
- {
- protected IZeroStore Store { get; private set; }
-
- const string NEW_ID = "new:";
-
- protected IStoreContext Context { get; private set; }
-
- protected bool IsCoreDatabase { get; private set; }
-
-
- public BackofficeApi(IStoreContext context)
- {
- Context = context;
- Store = context.Store;
- IsCoreDatabase = false;
- }
-
- internal BackofficeApi(IStoreContext context, bool isCoreDatabase) : this(context)
- {
- IsCoreDatabase = isCoreDatabase;
- }
-
-
- protected IZeroDocumentSession Session => Store.Session(IsCoreDatabase);
-
-
- ///
- public async Task GetById(string id) where T : ZeroIdEntity
- {
- if (id.IsNullOrWhiteSpace())
- {
- return default;
- }
-
- return await Session.LoadAsync(id);
- }
-
-
- ///
- public async Task> GetByIds(params string[] ids) where T : ZeroIdEntity
- {
- Dictionary models = await Session.LoadAsync(ids);
- Dictionary result = new Dictionary();
-
- foreach (string id in ids)
- {
- models.TryGetValue(id, out T model);
- result.Add(id, model);
- }
-
- return result;
- }
-
-
-
- ///
- public async Task> SaveModel(T model, IValidator validator = null, Action meta = null) where T : ZeroEntity
- {
- // check for alias
- //if (model is IUrlAliasEntity)
- //{
- // IUrlAliasEntity entity = operation.Model as IUrlAliasEntity;
- // entity.Alias = entity.Alias?.ToLower().ToUrlSegment();
- //}
-
- // run validator
- if (validator != null)
- {
- ValidationResult validation = await validator.ValidateAsync(model);
-
- if (!validation.IsValid)
- {
- return EntityResult.Fail(validation);
- }
- }
-
- // find all media items in model
- //List> media = ObjectTraverser.Find(model);
-
- // upload media items
- //Dictionary mediaItems = new Dictionary();
-
- //foreach (ObjectTraverser.Result item in media)
- //{
- // string id = item.Item?.Id;
-
- // if (!Media.Upload(item.Item, out bool uploaded, out string uploadError))
- // {
- // return EntityResult.Fail(item.Path, uploadError);
- // }
- // else
- // {
- // mediaItems.Add(id, item.Item);
- // }
- //}
-
- //if (operation.Media != null)
- //{
- // operation.Media?.Invoke(operation.Model, mediaItems);
- //}
-
- // find all Raven Ids
- List> ravenIds = ObjectTraverser.FindAttribute(model);
-
- // set unset Raven Ids
- foreach (ObjectTraverser.Result item in ravenIds)
- {
- string id = item.Property.GetValue(item.Parent, null) as string;
- if (String.IsNullOrWhiteSpace(id) || id.StartsWith(NEW_ID))
- {
- item.Property.SetValue(item.Parent, item.Item.Length.HasValue ? IdGenerator.Create(item.Item.Length.Value) : IdGenerator.Create());
- }
- }
-
- // get current user
- string userId = Context.Context.BackofficeUser.FindFirstValue(Constants.Auth.Claims.UserId);
-
- // set default properties
- if (model.Id.IsNullOrEmpty())
- {
- model.CreatedDate = DateTimeOffset.Now;
- model.CreatedById = userId;
- model.LanguageId = "languages.1-A"; // TODO correct language id
- }
-
- // update name alias and last modified
- model.Alias = Safenames.Alias(model.Name);
- model.LastModifiedById = userId;
- model.LastModifiedDate = DateTimeOffset.Now;
- model.CreatedById ??= userId;
- model.Hash ??= IdGenerator.Classic();
-
- Session.Advanced.WaitForIndexesAfterSaveChanges(throwOnTimeout: false);
-
- await Session.StoreAsync(model);
-
- meta?.Invoke(Session.Advanced.GetMetadataFor(model));
-
- await Session.SaveChangesAsync();
-
- return EntityResult.Success(model);
- }
-
-
-
- ///