Skip to content

Commit

Permalink
Merge pull request #119 from Lombiq/issue/LMBQ-124
Browse files Browse the repository at this point in the history
LMBQ-124: Orchard 1 Recipe Migration feature
  • Loading branch information
DemeSzabolcs authored Feb 27, 2023
2 parents 7a3d02c + fb801da commit cfd6983
Show file tree
Hide file tree
Showing 20 changed files with 1,838 additions and 0 deletions.
11 changes: 11 additions & 0 deletions Lombiq.HelpfulExtensions/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = {
// Setting root=true prevents ESLint from taking into account .eslintrc files higher up in the directory tree.
root: true,

// The following path may have to be adjusted to your directory structure.
extends: './node_modules/nodejs-extensions/config/.eslintrc.lombiq-base.js',

// Add custom rules and overrides here.
rules: {
},
};
8 changes: 8 additions & 0 deletions Lombiq.HelpfulExtensions/.stylelintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
// The following path may have to be adjusted to your directory structure.
extends: './node_modules/nodejs-extensions/config/.stylelintrc.lombiq-base.js',

// Add custom rules and overrides here.
rules: {
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using AngleSharp.Io;
using Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Localization;
using OrchardCore.Admin;
using OrchardCore.DisplayManagement.Notify;
using OrchardCore.Modules;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Linq;

namespace Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Controllers;

[Admin]
[Feature(FeatureIds.OrchardRecipeMigration)]
public class OrchardRecipeMigrationAdminController : Controller
{
private readonly INotifier _notifier;
private readonly IOrchardExportToRecipeConverter _converter;
private readonly IHtmlLocalizer<OrchardRecipeMigrationAdminController> H;
public OrchardRecipeMigrationAdminController(
INotifier notifier,
IOrchardExportToRecipeConverter converter,
IHtmlLocalizer<OrchardRecipeMigrationAdminController> localizer)
{
_notifier = notifier;
_converter = converter;
H = localizer;
}

public IActionResult Index() => View();

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Convert(IFormFile file)
{
Stream stream;
string json;

try
{
stream = file.OpenReadStream();
}
catch (Exception)
{
await _notifier.ErrorAsync(H["Please add a file to import."]);
return Redirect(nameof(Index));
}

await using (stream)
{
json = await _converter.ConvertAsync(XDocument.Load(stream));
}

Response.Headers.Add("Content-Disposition", "attachment;filename=export.recipe.json");
return Content(json, MimeTypeNames.ApplicationJson, Encoding.UTF8);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using OrchardCore.ContentManagement;

namespace Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Models;

/// <summary>
/// Stores Orchard 1 ID and container ID.
/// </summary>
public class OrchardIds : ContentPart
{
public string ExportId { get; set; }
public string Parent { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Controllers;
using Lombiq.HelpfulLibraries.OrchardCore.Navigation;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Localization;
using OrchardCore.Navigation;
using System;
using System.Threading.Tasks;

namespace Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Navigation;

public class AdminMenu : INavigationProvider
{
private readonly IHttpContextAccessor _hca;
private readonly IStringLocalizer T;

public AdminMenu(IHttpContextAccessor hca, IStringLocalizer<AdminMenu> stringLocalizer)
{
_hca = hca;
T = stringLocalizer;
}

public Task BuildNavigationAsync(string name, NavigationBuilder builder)
{
if (!name.EqualsOrdinalIgnoreCase("admin")) return Task.CompletedTask;

builder.Add(T["Configuration"], configuration => configuration
.Add(T["Import/Export"], importExport => importExport
.Add(T["Orchard 1 Recipe Migration"], T["Orchard 1 Recipe Migration"], migration => migration
.Action<OrchardRecipeMigrationAdminController>(_hca.HttpContext, controller => controller.Index())
.LocalNav()
)));

return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Models;
using OrchardCore.Alias.Models;
using OrchardCore.Autoroute.Models;
using OrchardCore.ContentManagement;
using OrchardCore.Html.Models;
using OrchardCore.Title.Models;
using System;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Linq;

namespace Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Services;

public class CommonOrchardContentConverter : IOrchardContentConverter
{
public int Order => 0;

public bool IsApplicable(XElement element) => true;

public Task ImportAsync(XElement element, ContentItem contentItem)
{
bool HasElementAttribute(string elementName, string attributeName, out string value)
{
value = element.Element(elementName)?.Attribute(attributeName)?.Value.Trim();
return !string.IsNullOrWhiteSpace(value);
}

var exportId = element.Attribute("Id")?.Value.Trim();
contentItem.Alter<OrchardIds>(ids => ids.ExportId = exportId);
if (exportId?.StartsWithOrdinal("/alias=") == true)
{
AlterIfPartExists<AliasPart>(contentItem, part => part.Alias = GetAlias(exportId));
}

if (element.Attribute("Status")?.Value == "Published")
{
contentItem.Published = true;
contentItem.Latest = true;
}

ImportCommonPart(contentItem, element);

if (element.Element("AutoroutePart") is { } autoroutePart &&
autoroutePart.Attribute("Alias")?.Value is { } autoroutePartAlias)
{
AlterIfPartExists<AutoroutePart>(contentItem, part =>
{
part.Path = autoroutePartAlias;
part.SetHomepage = autoroutePart.Attribute("PromoteToHomePage")?.Value == "true";
});
}

if (HasElementAttribute("BodyPart", "Text", out var body))
{
AlterIfPartExists<HtmlBodyPart>(contentItem, part => part.Html = body);
}

if (HasElementAttribute("TitlePart", "Title", out var title))
{
contentItem.DisplayText = title;
AlterIfPartExists<TitlePart>(contentItem, part => part.Title = title);
}

if (HasElementAttribute("IdentityPart", "Identifier", out var id))
{
if (id.Length < 26) id = new StringBuilder(id).Append('0', 26 - id.Length).ToString();
contentItem.ContentItemId = id[..26];
}

return Task.CompletedTask;
}

private static string GetAlias(string value) => value.Split("/alias=").Last().Trim().Replace("\\/", "/");

private static void AlterIfPartExists<TPart>(ContentItem contentItem, Action<TPart> action)
where TPart : ContentPart, new()
{
if (contentItem.Has<TPart>()) contentItem.Alter(action);
}

public static void ImportCommonPart(ContentItem contentItem, XElement parentElement)
{
if (parentElement.Element("CommonPart") is not { } commonPart) return;

string Attribute(string name) => commonPart.Attribute(name)?.Value;

DateTime? Date(string name) => DateTime.TryParse(Attribute(name), out var date) ? date : null;

if (Attribute("Owner")?.Replace("/User.UserName=", string.Empty) is { } owner)
{
contentItem.Author = owner;
}

contentItem.CreatedUtc = Date("CreatedUtc") ?? contentItem.CreatedUtc;
contentItem.PublishedUtc = Date("PublishedUtc") ?? contentItem.PublishedUtc;
contentItem.ModifiedUtc = Date("ModifiedUtc") ?? contentItem.ModifiedUtc;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using GraphQL.Execution;
using OrchardCore.ContentManagement;
using System;
using System.Threading.Tasks;
using System.Xml.Linq;

namespace Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Services;

public class GraphMetadataOrchardContentConverter : IOrchardContentConverter
{
public const string GraphMetadata = nameof(GraphMetadata);

public bool IsApplicable(XElement element) => element.Element(GraphMetadata) != null;

public Task ImportAsync(XElement element, ContentItem contentItem)
{
contentItem.ContentItemId = ToContentItemId(
element.Element(GraphMetadata)!.Attribute("NodeId")?.Value,
element);

return Task.CompletedTask;
}

public static string ToContentItemId(string value, XElement element)
{
var id = value.ToTechnicalInt();
if (id < 0) throw new InvalidOperationError($"Missing or corrupted {GraphMetadata} node ID.\n{element}");
return "assoc" + id.PadZeroes(21);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using OrchardCore.ContentManagement;
using System.Threading.Tasks;
using System.Xml.Linq;

namespace Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Services;

/// <summary>
/// A converter used to port information from a &lt;Content&gt; item from an Orchard 1 XML export into an Orchard Core
/// <see cref="ContentItem"/>.
/// </summary>
public interface IOrchardContentConverter
{
/// <summary>
/// Gets the sorting order number, lower numbers are used first.
/// </summary>
int Order => 10;

/// <summary>
/// Returns <see langword="true"/> if this converter can use the given <paramref name="element"/>.
/// </summary>
bool IsApplicable(XElement element);

/// <summary>
/// Returns a new <see cref="ContentItem"/> created for this element, or <see langword="null"/> if this converter
/// can't create a content item for the given <paramref name="element"/>. If all converters returned <see
/// langword="null"/>, <see cref="IContentManager.NewAsync"/> is used with the <paramref name="element"/>'s <see
/// cref="XElement.Name"/> as the content type.
/// </summary>
Task<ContentItem> CreateContentItemAsync(XElement element) => Task.FromResult<ContentItem>(null);

/// <summary>
/// Processes further content in the <paramref name="element"/> to fill the <paramref name="contentItem"/>.
/// </summary>
Task ImportAsync(XElement element, ContentItem contentItem);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using OrchardCore.ContentManagement;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Xml.Linq;

namespace Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Services;

/// <summary>
/// A converter for the whole Orchard 1 XML export file.
/// </summary>
public interface IOrchardExportConverter
{
/// <summary>
/// Updates the collection of <paramref name="contentItems"/> using data from the whole XML file. This is invoked
/// before the recipe steps are serialized.
/// </summary>
Task UpdateContentItemsAsync(XDocument document, IList<ContentItem> contentItems);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Threading.Tasks;
using System.Xml.Linq;

namespace Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Services;

/// <summary>
/// Converts old Orchard 1 export files into Orchard Core recipe files. Besides the built-in content conversion for some
/// common parts, its functionality can be expanded by registering additional services that implement <see
/// cref="IOrchardContentConverter"/> or <see cref="IOrchardExportConverter"/> recipes.
/// </summary>
public interface IOrchardExportToRecipeConverter
{
/// <summary>
/// Returns a JSON string that contains an Orchard Core recipe file based on the provided Orchard 1 <paramref
/// name="export"/> XML.
/// </summary>
Task<string> ConvertAsync(XDocument export);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Models;
using OrchardCore.ContentManagement;
using OrchardCore.Lists.Models;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Linq;

namespace Lombiq.HelpfulExtensions.Extensions.OrchardRecipeMigration.Services;

/// <summary>
/// A post-processing converter that looks for added <see cref="OrchardIds"/> content parts in the prepared list of
/// content items where the <see cref="OrchardIds.Parent"/> is set by custom converters. If the content item of the
/// parent ID has <see cref="ListPart"/> then the child is assigned to its list.
/// </summary>
public class ListPartOrchardExportConverter : IOrchardExportConverter
{
public Task UpdateContentItemsAsync(XDocument document, IList<ContentItem> contentItems)
{
var itemsById = contentItems
.Where(item => !string.IsNullOrEmpty(item.As<OrchardIds>()?.ExportId))
.ToDictionary(item => item.As<OrchardIds>().ExportId);

foreach (var item in itemsById.Values.Where(item => !string.IsNullOrEmpty(item.As<OrchardIds>().Parent)))
{
var parentId = item.As<OrchardIds>().Parent;
if (!itemsById.TryGetValue(parentId, out var parent) || !parent.Has<ListPart>()) continue;

item.Alter<ContainedPart>(part =>
{
part.ListContentItemId = parent.ContentItemId;
part.ListContentType = parent.ContentType;
});
}

return Task.CompletedTask;
}
}
Loading

0 comments on commit cfd6983

Please sign in to comment.