diff --git a/src/Umbraco.TestData/Properties/AssemblyInfo.cs b/src/Umbraco.TestData/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..3c4251cdf6
--- /dev/null
+++ b/src/Umbraco.TestData/Properties/AssemblyInfo.cs
@@ -0,0 +1,36 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+// General Information about an assembly is controlled through the following
+// set of attributes. Change these attribute values to modify the information
+// associated with an assembly.
+[assembly: AssemblyTitle("Umbraco.TestData")]
+[assembly: AssemblyDescription("")]
+[assembly: AssemblyConfiguration("")]
+[assembly: AssemblyCompany("")]
+[assembly: AssemblyProduct("Umbraco.TestData")]
+[assembly: AssemblyCopyright("Copyright © 2019")]
+[assembly: AssemblyTrademark("")]
+[assembly: AssemblyCulture("")]
+
+// Setting ComVisible to false makes the types in this assembly not visible
+// to COM components. If you need to access a type in this assembly from
+// COM, set the ComVisible attribute to true on that type.
+[assembly: ComVisible(false)]
+
+// The following GUID is for the ID of the typelib if this project is exposed to COM
+[assembly: Guid("fb5676ed-7a69-492c-b802-e7b24144c0fc")]
+
+// Version information for an assembly consists of the following four values:
+//
+// Major Version
+// Minor Version
+// Build Number
+// Revision
+//
+// You can specify all the values or you can default the Build and Revision Numbers
+// by using the '*' as shown below:
+// [assembly: AssemblyVersion("1.0.*")]
+[assembly: AssemblyVersion("1.0.0.0")]
+[assembly: AssemblyFileVersion("1.0.0.0")]
diff --git a/src/Umbraco.TestData/SegmentTestController.cs b/src/Umbraco.TestData/SegmentTestController.cs
new file mode 100644
index 0000000000..bcfdfc4ac4
--- /dev/null
+++ b/src/Umbraco.TestData/SegmentTestController.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using System.Configuration;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Web.Mvc;
+using Umbraco.Core;
+using Umbraco.Core.Models;
+using Umbraco.Web.Mvc;
+
+namespace Umbraco.TestData
+{
+ public class SegmentTestController : SurfaceController
+ {
+
+ public ActionResult EnableDocTypeSegments(string alias, string propertyTypeAlias)
+ {
+ if (ConfigurationManager.AppSettings["Umbraco.TestData.Enabled"] != "true")
+ return HttpNotFound();
+
+ var ct = Services.ContentTypeService.Get(alias);
+ if (ct == null)
+ return Content($"No document type found by alias {alias}");
+
+ var propType = ct.PropertyTypes.FirstOrDefault(x => x.Alias == propertyTypeAlias);
+ if (propType == null)
+ return Content($"The document type {alias} does not have a property type {propertyTypeAlias ?? "null"}");
+
+ if (ct.Variations.VariesBySegment())
+ return Content($"The document type {alias} already allows segments, nothing has been changed");
+
+ ct.Variations = ct.Variations.SetFlag(ContentVariation.Segment);
+
+ propType.Variations = propType.Variations.SetFlag(ContentVariation.Segment);
+
+ Services.ContentTypeService.Save(ct);
+ return Content($"The document type {alias} and property type {propertyTypeAlias} now allows segments");
+ }
+
+ public ActionResult DisableDocTypeSegments(string alias)
+ {
+ if (ConfigurationManager.AppSettings["Umbraco.TestData.Enabled"] != "true")
+ return HttpNotFound();
+
+ var ct = Services.ContentTypeService.Get(alias);
+ if (ct == null)
+ return Content($"No document type found by alias {alias}");
+
+ if (!ct.VariesBySegment())
+ return Content($"The document type {alias} does not allow segments, nothing has been changed");
+
+ ct.Variations = ct.Variations.UnsetFlag(ContentVariation.Segment);
+
+ Services.ContentTypeService.Save(ct);
+ return Content($"The document type {alias} no longer allows segments");
+ }
+
+ public ActionResult AddSegmentData(int contentId, string propertyAlias, string value, string segment, string culture = null)
+ {
+ var content = Services.ContentService.GetById(contentId);
+ if (content == null)
+ return Content($"No content found by id {contentId}");
+
+ if (!content.HasProperty(propertyAlias))
+ return Content($"The content by id {contentId} does not contain a property with alias {propertyAlias}");
+
+ if (content.ContentType.VariesByCulture() && culture.IsNullOrWhiteSpace())
+ return Content($"The content by id {contentId} varies by culture but no culture was specified");
+
+ content.SetValue(propertyAlias, value, culture, segment);
+ Services.ContentService.Save(content);
+
+ return Content($"Segment value has been set on content {contentId} for property {propertyAlias}");
+ }
+ }
+}
diff --git a/src/Umbraco.TestData/Umbraco.TestData.csproj b/src/Umbraco.TestData/Umbraco.TestData.csproj
new file mode 100644
index 0000000000..d61321ebb8
--- /dev/null
+++ b/src/Umbraco.TestData/Umbraco.TestData.csproj
@@ -0,0 +1,70 @@
+
+
+
+
+ Debug
+ AnyCPU
+ {FB5676ED-7A69-492C-B802-E7B24144C0FC}
+ Library
+ Properties
+ Umbraco.TestData
+ Umbraco.TestData
+ v4.7.2
+ 512
+ true
+
+
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {31785bc3-256c-4613-b2f5-a1b0bdded8c1}
+ Umbraco.Core
+
+
+ {651e1350-91b6-44b7-bd60-7207006d7003}
+ Umbraco.Web
+
+
+
+
+ 28.4.4
+
+
+ 5.2.7
+
+
+
+
\ No newline at end of file
diff --git a/src/Umbraco.TestData/UmbracoTestDataController.cs b/src/Umbraco.TestData/UmbracoTestDataController.cs
new file mode 100644
index 0000000000..402c05cc1c
--- /dev/null
+++ b/src/Umbraco.TestData/UmbracoTestDataController.cs
@@ -0,0 +1,288 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Umbraco.Core;
+using System.Web.Mvc;
+using Umbraco.Core.Cache;
+using Umbraco.Core.Logging;
+using Umbraco.Core.Models;
+using Umbraco.Core.Persistence;
+using Umbraco.Core.PropertyEditors;
+using Umbraco.Core.Services;
+using Umbraco.Web;
+using Umbraco.Web.Mvc;
+using System.Configuration;
+using Bogus;
+using Umbraco.Core.Scoping;
+
+namespace Umbraco.TestData
+{
+ ///
+ /// Creates test data
+ ///
+ public class UmbracoTestDataController : SurfaceController
+ {
+ private const string RichTextDataTypeName = "UmbracoTestDataContent.RTE";
+ private const string MediaPickerDataTypeName = "UmbracoTestDataContent.MediaPicker";
+ private const string TextDataTypeName = "UmbracoTestDataContent.Text";
+ private const string TestDataContentTypeAlias = "umbTestDataContent";
+ private readonly IScopeProvider _scopeProvider;
+ private readonly PropertyEditorCollection _propertyEditors;
+
+ public UmbracoTestDataController(IScopeProvider scopeProvider, PropertyEditorCollection propertyEditors, IUmbracoContextAccessor umbracoContextAccessor, IUmbracoDatabaseFactory databaseFactory, ServiceContext services, AppCaches appCaches, ILogger logger, IProfilingLogger profilingLogger, UmbracoHelper umbracoHelper) : base(umbracoContextAccessor, databaseFactory, services, appCaches, logger, profilingLogger, umbracoHelper)
+ {
+ _scopeProvider = scopeProvider;
+ _propertyEditors = propertyEditors;
+ }
+
+ ///
+ /// Creates a content and associated media tree (hierarchy)
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// Each content item created is associated to a media item via a media picker and therefore a relation is created between the two
+ ///
+ public ActionResult CreateTree(int count, int depth, string locale = "en")
+ {
+ if (ConfigurationManager.AppSettings["Umbraco.TestData.Enabled"] != "true")
+ return HttpNotFound();
+
+ if (!Validate(count, depth, out var message, out var perLevel))
+ throw new InvalidOperationException(message);
+
+ var faker = new Faker(locale);
+ var company = faker.Company.CompanyName();
+
+ using (var scope = _scopeProvider.CreateScope())
+ {
+ var imageIds = CreateMediaTree(company, faker, count, depth).ToList();
+ var contentIds = CreateContentTree(company, faker, count, depth, imageIds, out var root).ToList();
+
+ Services.ContentService.SaveAndPublishBranch(root, true);
+
+ scope.Complete();
+ }
+
+
+ return Content("Done");
+ }
+
+ private bool Validate(int count, int depth, out string message, out int perLevel)
+ {
+ perLevel = 0;
+ message = null;
+
+ if (count <= 0)
+ {
+ message = "Count must be more than 0";
+ return false;
+ }
+
+ perLevel = count / depth;
+ if (perLevel < 1)
+ {
+ message = "Count not high enough for specified for number of levels required";
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Utility to create a tree hierarchy
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ /// A callback that returns a tuple of Content and another callback to produce a Container.
+ /// For media, a container will be another folder, for content the container will be the Content itself.
+ ///
+ ///
+ private IEnumerable CreateHierarchy(
+ T parent, int count, int depth,
+ Func container)> create)
+ where T: class, IContentBase
+ {
+ yield return parent.GetUdi();
+
+ // This will not calculate a balanced tree but it will ensure that there will be enough nodes deep enough to not fill up the tree.
+ var totalDescendants = count - 1;
+ var perLevel = Math.Ceiling(totalDescendants / (double)depth);
+ var perBranch = Math.Ceiling(perLevel / depth);
+
+ var tracked = new Stack<(T parent, int childCount)>();
+
+ var currChildCount = 0;
+
+ for (int i = 0; i < count; i++)
+ {
+ var created = create(parent);
+ var contentItem = created.content;
+
+ yield return contentItem.GetUdi();
+
+ currChildCount++;
+
+ if (currChildCount == perBranch)
+ {
+ // move back up...
+
+ var prev = tracked.Pop();
+
+ // restore child count
+ currChildCount = prev.childCount;
+ // restore the parent
+ parent = prev.parent;
+
+ }
+ else if (contentItem.Level < depth)
+ {
+ // track the current parent and it's current child count
+ tracked.Push((parent, currChildCount));
+
+ // not at max depth, create below
+ parent = created.container();
+
+ currChildCount = 0;
+ }
+
+ }
+ }
+
+ ///
+ /// Creates the media tree hiearachy
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ private IEnumerable CreateMediaTree(string company, Faker faker, int count, int depth)
+ {
+ var parent = Services.MediaService.CreateMediaWithIdentity(company, -1, Constants.Conventions.MediaTypes.Folder);
+
+ return CreateHierarchy(parent, count, depth, currParent =>
+ {
+ var imageUrl = faker.Image.PicsumUrl();
+
+ // we are appending a &ext=.jpg to the end of this for a reason. The result of this url will be something like:
+ // https://picsum.photos/640/480/?image=106
+ // and due to the way that we detect images there must be an extension so we'll change it to
+ // https://picsum.photos/640/480/?image=106&ext=.jpg
+ // which will trick our app into parsing this and thinking it's an image ... which it is so that's good.
+ // if we don't do this we don't get thumbnails in the back office.
+ imageUrl += "&ext=.jpg";
+
+ var media = Services.MediaService.CreateMedia(faker.Commerce.ProductName(), currParent, Constants.Conventions.MediaTypes.Image);
+ media.SetValue(Constants.Conventions.Media.File, imageUrl);
+ Services.MediaService.Save(media);
+ return (media, () =>
+ {
+ // create a folder to contain child media
+ var container = Services.MediaService.CreateMediaWithIdentity(faker.Commerce.Department(), currParent, Constants.Conventions.MediaTypes.Folder);
+ return container;
+ });
+ });
+ }
+
+ ///
+ /// Creates the content tree hiearachy
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ private IEnumerable CreateContentTree(string company, Faker faker, int count, int depth, List imageIds, out IContent root)
+ {
+ var random = new Random(company.GetHashCode());
+
+ var docType = GetOrCreateContentType();
+
+ var parent = Services.ContentService.Create(company, -1, docType.Alias);
+ parent.SetValue("review", faker.Rant.Review());
+ parent.SetValue("desc", company);
+ parent.SetValue("media", imageIds[random.Next(0, imageIds.Count - 1)]);
+ Services.ContentService.Save(parent);
+
+ root = parent;
+
+ return CreateHierarchy(parent, count, depth, currParent =>
+ {
+ var content = Services.ContentService.Create(faker.Commerce.ProductName(), currParent, docType.Alias);
+ content.SetValue("review", faker.Rant.Review());
+ content.SetValue("desc", string.Join(", ", Enumerable.Range(0, 5).Select(x => faker.Commerce.ProductAdjective()))); ;
+ content.SetValue("media", imageIds[random.Next(0, imageIds.Count - 1)]);
+
+ Services.ContentService.Save(content);
+ return (content, () => content);
+ });
+
+ }
+
+ private IContentType GetOrCreateContentType()
+ {
+ var docType = Services.ContentTypeService.Get(TestDataContentTypeAlias);
+ if (docType != null)
+ return docType;
+
+ docType = new ContentType(-1)
+ {
+ Alias = TestDataContentTypeAlias,
+ Name = "Umbraco Test Data Content",
+ Icon = "icon-science color-green"
+ };
+ docType.AddPropertyGroup("Content");
+ docType.AddPropertyType(new PropertyType(GetOrCreateRichText(), "review")
+ {
+ Name = "Review"
+ });
+ docType.AddPropertyType(new PropertyType(GetOrCreateMediaPicker(), "media")
+ {
+ Name = "Media"
+ });
+ docType.AddPropertyType(new PropertyType(GetOrCreateText(), "desc")
+ {
+ Name = "Description"
+ });
+ Services.ContentTypeService.Save(docType);
+ docType.AllowedContentTypes = new[] { new ContentTypeSort(docType.Id, 0) };
+ Services.ContentTypeService.Save(docType);
+ return docType;
+ }
+
+ private IDataType GetOrCreateRichText() => GetOrCreateDataType(RichTextDataTypeName, Constants.PropertyEditors.Aliases.TinyMce);
+
+ private IDataType GetOrCreateMediaPicker() => GetOrCreateDataType(MediaPickerDataTypeName, Constants.PropertyEditors.Aliases.MediaPicker);
+
+ private IDataType GetOrCreateText() => GetOrCreateDataType(TextDataTypeName, Constants.PropertyEditors.Aliases.TextBox);
+
+ private IDataType GetOrCreateDataType(string name, string editorAlias)
+ {
+ var dt = Services.DataTypeService.GetDataType(name);
+ if (dt != null) return dt;
+
+ var editor = _propertyEditors.FirstOrDefault(x => x.Alias == editorAlias);
+ if (editor == null)
+ throw new InvalidOperationException($"No {editorAlias} editor found");
+
+ dt = new DataType(editor)
+ {
+ Name = name,
+ Configuration = editor.GetConfigurationEditor().DefaultConfigurationObject,
+ DatabaseType = ValueStorageType.Ntext
+ };
+
+ Services.DataTypeService.Save(dt);
+ return dt;
+ }
+ }
+}
diff --git a/src/Umbraco.TestData/readme.md b/src/Umbraco.TestData/readme.md
new file mode 100644
index 0000000000..f943326303
--- /dev/null
+++ b/src/Umbraco.TestData/readme.md
@@ -0,0 +1,51 @@
+## Umbraco Test Data
+
+This project is a utility to be able to generate large amounts of content and media in an
+Umbraco installation for testing.
+
+Currently this project is referenced in the Umbraco.Web.UI project but only when it's being built
+in Debug mode (i.e. when testing within Visual Studio).
+
+## Usage
+
+You must use SQL Server for this, using SQLCE will die if you try to bulk create huge amounts of data.
+
+It has to be enabled by an appSetting:
+
+```xml
+
+```
+
+Once this is enabled this endpoint can be executed:
+
+`/umbraco/surface/umbracotestdata/CreateTree?count=100&depth=5`
+
+The query string options are:
+
+* `count` = the number of content and media nodes to create
+* `depth` = how deep the trees created will be
+* `locale` (optional, default = "en") = the language that the data will be generated in
+
+This creates a content and associated media tree (hierarchy). Each content item created is associated
+to a media item via a media picker and therefore a relation is created between the two. Each content and
+media tree created have the same root node name so it's easy to know which content branch relates to
+which media branch.
+
+All values are generated using the very handy `Bogus` package.
+
+## Schema
+
+This will install some schema items:
+
+* `umbTestDataContent` Document Type. __TIP__: If you want to delete all of the content data generated with this tool, just delete this content type
+* `UmbracoTestDataContent.RTE` Data Type
+* `UmbracoTestDataContent.MediaPicker` Data Type
+* `UmbracoTestDataContent.Text` Data Type
+
+For media, the normal folder and image is used
+
+## Media
+
+This does not upload physical files, it just uses a randomized online image as the `umbracoFile` value.
+This works when viewing the media item in the media section and the image will show up and with recent changes this will also work
+when editing content to view the thumbnail for the picked media.
diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj
index a2dfa0ffd2..96701c1c4e 100644
--- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj
+++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj
@@ -126,6 +126,10 @@
{52ac0ba8-a60e-4e36-897b-e8b97a54ed1c}
Umbraco.ModelsBuilder.Embedded
+
+ {fb5676ed-7a69-492c-b802-e7b24144c0fc}
+ Umbraco.TestData
+
{651e1350-91b6-44b7-bd60-7207006d7003}
Umbraco.Web
diff --git a/src/Umbraco.Web.UI/web.Template.Debug.config b/src/Umbraco.Web.UI/web.Template.Debug.config
index ff42f098f7..0026f23514 100644
--- a/src/Umbraco.Web.UI/web.Template.Debug.config
+++ b/src/Umbraco.Web.UI/web.Template.Debug.config
@@ -21,6 +21,11 @@
+
+
+
+
+
diff --git a/src/umbraco.sln b/src/umbraco.sln
index ba9df633bb..a747f21d19 100644
--- a/src/umbraco.sln
+++ b/src/umbraco.sln
@@ -104,6 +104,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "IssueTemplates", "IssueTemp
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.ModelsBuilder.Embedded", "Umbraco.ModelsBuilder.Embedded\Umbraco.ModelsBuilder.Embedded.csproj", "{52AC0BA8-A60E-4E36-897B-E8B97A54ED1C}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.TestData", "Umbraco.TestData\Umbraco.TestData.csproj", "{FB5676ED-7A69-492C-B802-E7B24144C0FC}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -140,6 +142,10 @@ Global
{52AC0BA8-A60E-4E36-897B-E8B97A54ED1C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{52AC0BA8-A60E-4E36-897B-E8B97A54ED1C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{52AC0BA8-A60E-4E36-897B-E8B97A54ED1C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FB5676ED-7A69-492C-B802-E7B24144C0FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FB5676ED-7A69-492C-B802-E7B24144C0FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FB5676ED-7A69-492C-B802-E7B24144C0FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FB5676ED-7A69-492C-B802-E7B24144C0FC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -152,6 +158,7 @@ Global
{53594E5B-64A2-4545-8367-E3627D266AE8} = {FD962632-184C-4005-A5F3-E705D92FC645}
{3A33ADC9-C6C0-4DB1-A613-A9AF0210DF3D} = {B5BD12C1-A454-435E-8A46-FF4A364C0382}
{C7311C00-2184-409B-B506-52A5FAEA8736} = {FD962632-184C-4005-A5F3-E705D92FC645}
+ {FB5676ED-7A69-492C-B802-E7B24144C0FC} = {B5BD12C1-A454-435E-8A46-FF4A364C0382}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {7A0F2E34-D2AF-4DAB-86A0-7D7764B3D0EC}