Refactor dictionary bulk action APIs (#13786)

* Sanitize dictionary overview and export actions

* Amend dictionary services with async and attempt pattern + isolate temporary file handling in its own service.

* Update OpenAPI schema to match new dictionary bulk actions

* Update src/Umbraco.Cms.Api.Management/Services/DictionaryItemImportService.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Cms.Api.Management/Services/DictionaryItemImportService.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Cms.Api.Management/Services/TemporaryFileService.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Update src/Umbraco.Cms.Api.Management/ViewModels/Dictionary/DictionaryImportModel.cs

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>

* Refactor - split dictionary import handling into smaller methods

* Rename view model due to name clash in new SwaggerGen + update OpenAPI JSON file

---------

Co-authored-by: Mole <nikolajlauridsen@protonmail.ch>
This commit is contained in:
Kenn Jacobsen
2023-02-08 13:18:08 +01:00
committed by GitHub
parent b582bfa39b
commit fdf416550a
27 changed files with 395 additions and 320 deletions

View File

@@ -22,42 +22,15 @@ public class AllDictionaryController : DictionaryControllerBase
[HttpGet]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel<DictionaryOverviewViewModel>), StatusCodes.Status200OK)]
// FIXME: make this action slim (move logic somewhere else)
public async Task<ActionResult<PagedViewModel<DictionaryOverviewViewModel>>> All(int skip = 0, int take = 100)
{
// unfortunately we can't paginate here...we'll have to get all and paginate in memory
IDictionaryItem[] items = (await _dictionaryItemService.GetDescendantsAsync(null)).ToArray();
var list = new List<DictionaryOverviewViewModel>(items.Length);
// Build the proper tree structure, as we can have nested dictionary items
BuildTree(list, items);
var model = new PagedViewModel<DictionaryOverviewViewModel>
{
Total = list.Count,
Items = list.Skip(skip).Take(take),
Total = items.Length,
Items = _umbracoMapper.MapEnumerable<IDictionaryItem, DictionaryOverviewViewModel>(items.Skip(skip).Take(take))
};
return await Task.FromResult(model);
}
// recursive method to build a tree structure from the flat structure returned above
private void BuildTree(List<DictionaryOverviewViewModel> list, IDictionaryItem[] items, int level = 0, Guid? parentId = null)
{
IDictionaryItem[] children = items.Where(t => t.ParentId == parentId).ToArray();
if (children.Any() == false)
{
return;
}
foreach (IDictionaryItem child in children.OrderBy(item => item.ItemKey))
{
DictionaryOverviewViewModel? display = _umbracoMapper.Map<IDictionaryItem, DictionaryOverviewViewModel>(child);
if (display is not null)
{
display.Level = level;
list.Add(display);
}
BuildTree(list, items, level + 1, child.Key);
}
}
}

View File

@@ -11,23 +11,22 @@ namespace Umbraco.Cms.Api.Management.Controllers.Dictionary;
public class ExportDictionaryController : DictionaryControllerBase
{
// FIXME: use IDictionaryItemService instead of ILocalizationService
private readonly ILocalizationService _localizationService;
private readonly IDictionaryItemService _dictionaryItemService;
private readonly IEntityXmlSerializer _entityXmlSerializer;
public ExportDictionaryController(ILocalizationService localizationService, IEntityXmlSerializer entityXmlSerializer)
public ExportDictionaryController(IDictionaryItemService dictionaryItemService, IEntityXmlSerializer entityXmlSerializer)
{
_localizationService = localizationService;
_dictionaryItemService = dictionaryItemService;
_entityXmlSerializer = entityXmlSerializer;
}
[HttpGet("export/{key:guid}")]
[HttpGet("{key:guid}/export")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(NotFoundResult), StatusCodes.Status404NotFound)]
public async Task<IActionResult> ExportDictionary(Guid key, bool includeChildren = false)
public async Task<IActionResult> Export(Guid key, bool includeChildren = false)
{
IDictionaryItem? dictionaryItem = _localizationService.GetDictionaryItemById(key);
IDictionaryItem? dictionaryItem = await _dictionaryItemService.GetAsync(key);
if (dictionaryItem is null)
{
return await Task.FromResult(NotFound());
@@ -35,11 +34,6 @@ public class ExportDictionaryController : DictionaryControllerBase
XElement xml = _entityXmlSerializer.Serialize(dictionaryItem, includeChildren);
var fileName = $"{dictionaryItem.ItemKey}.udt";
// Set custom header so umbRequestHelper.downloadFile can save the correct filename
HttpContext.Response.Headers.Add("x-filename", fileName);
return await Task.FromResult(File(Encoding.UTF8.GetBytes(xml.ToDataString()), MediaTypeNames.Application.Octet, fileName));
return await Task.FromResult(File(Encoding.UTF8.GetBytes(xml.ToDataString()), MediaTypeNames.Application.Octet, $"{dictionaryItem.ItemKey}.udt"));
}
}

View File

@@ -1,52 +1,53 @@
using System.Net.Mime;
using System.Text;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Api.Common.Builders;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Api.Management.Services;
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
using Umbraco.Cms.Api.Management.Services.OperationStatus;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Api.Management.Controllers.Dictionary;
public class ImportDictionaryController : DictionaryControllerBase
{
private readonly IDictionaryService _dictionaryService;
private readonly IWebHostEnvironment _webHostEnvironment;
private readonly ILoadDictionaryItemService _loadDictionaryItemService;
private readonly IDictionaryItemImportService _dictionaryItemImportService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public ImportDictionaryController(
IDictionaryService dictionaryService,
IWebHostEnvironment webHostEnvironment,
ILoadDictionaryItemService loadDictionaryItemService)
IDictionaryItemImportService dictionaryItemImportService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_dictionaryService = dictionaryService;
_webHostEnvironment = webHostEnvironment;
_loadDictionaryItemService = loadDictionaryItemService;
_dictionaryItemImportService = dictionaryItemImportService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[HttpPost("import")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(ContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(NotFoundResult), StatusCodes.Status404NotFound)]
public async Task<IActionResult> ImportDictionary(string file, int? parentId)
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Import(ViewModels.Dictionary.DictionaryImportModel dictionaryImportModel)
{
if (string.IsNullOrWhiteSpace(file))
Attempt<IDictionaryItem?, DictionaryImportOperationStatus> result = await _dictionaryItemImportService
.ImportDictionaryItemFromUdtFileAsync(
dictionaryImportModel.FileName,
dictionaryImportModel.ParentKey,
CurrentUserId(_backOfficeSecurityAccessor));
return result.Status switch
{
return NotFound();
}
var filePath = Path.Combine(_webHostEnvironment.MapPathContentRoot(Constants.SystemDirectories.Data), file);
if (_webHostEnvironment.ContentRootFileProvider.GetFileInfo(filePath) is null)
{
return await Task.FromResult(NotFound());
}
IDictionaryItem dictionaryItem = _loadDictionaryItemService.Load(filePath, parentId);
return await Task.FromResult(Content(_dictionaryService.CalculatePath(dictionaryItem.ParentId, dictionaryItem.Id), MediaTypeNames.Text.Plain, Encoding.UTF8));
DictionaryImportOperationStatus.Success => CreatedAtAction<ByKeyDictionaryController>(controller => nameof(controller.ByKey), result.Result!.Key),
DictionaryImportOperationStatus.ParentNotFound => NotFound("The parent dictionary item could not be found."),
DictionaryImportOperationStatus.InvalidFileType => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Invalid file type")
.WithDetail("The dictionary import only supports UDT files.")
.Build()),
DictionaryImportOperationStatus.InvalidFileContent => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Invalid file content")
.WithDetail("The uploaded file could not be read as a valid UDT file.")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown dictionary import operation status")
};
}
}

View File

@@ -1,52 +1,46 @@
using System.Xml;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.Builders;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Api.Management.Models;
using Umbraco.Cms.Api.Management.Services;
using Umbraco.Cms.Api.Management.Services.OperationStatus;
using Umbraco.Cms.Api.Management.ViewModels.Dictionary;
using Umbraco.Extensions;
using Umbraco.New.Cms.Core.Factories;
using Umbraco.Cms.Core;
namespace Umbraco.Cms.Api.Management.Controllers.Dictionary;
public class UploadDictionaryController : DictionaryControllerBase
{
private readonly ILocalizedTextService _localizedTextService;
private readonly IUploadFileService _uploadFileService;
private readonly IDictionaryFactory _dictionaryFactory;
public UploadDictionaryController(ILocalizedTextService localizedTextService, IUploadFileService uploadFileService, IDictionaryFactory dictionaryFactory)
public UploadDictionaryController(IUploadFileService uploadFileService, IDictionaryFactory dictionaryFactory)
{
_localizedTextService = localizedTextService;
_uploadFileService = uploadFileService;
_dictionaryFactory = dictionaryFactory;
}
[HttpPost("upload")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(DictionaryImportViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(DictionaryUploadViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<DictionaryImportViewModel>> Upload(IFormFile file)
public async Task<IActionResult> Upload(IFormFile file)
{
FormFileUploadResult formFileUploadResult = _uploadFileService.TryLoad(file);
if (formFileUploadResult.CouldLoad is false || formFileUploadResult.XmlDocument is null)
Attempt<UdtFileUpload, UdtFileUploadOperationStatus> result = await _uploadFileService.UploadUdtFileAsync(file);
return result.Status switch
{
return await Task.FromResult(ValidationProblem(
_localizedTextService.Localize("media", "failedFileUpload"),
formFileUploadResult.ErrorMessage));
}
DictionaryImportViewModel model = _dictionaryFactory.CreateDictionaryImportViewModel(formFileUploadResult);
if (!model.DictionaryItems.Any())
{
return ValidationProblem(
_localizedTextService.Localize("media", "failedFileUpload"),
_localizedTextService.Localize("dictionary", "noItemsInFile"));
}
return await Task.FromResult(model);
UdtFileUploadOperationStatus.Success => Ok(_dictionaryFactory.CreateDictionaryImportViewModel(result.Result)),
UdtFileUploadOperationStatus.InvalidFileType => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Invalid file type")
.WithDetail("The dictionary import only supports UDT files.")
.Build()),
UdtFileUploadOperationStatus.InvalidFileContent => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Invalid file content")
.WithDetail("The uploaded file could not be read as a valid UDT file.")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown UDT file upload operation status")
};
}
}

View File

@@ -13,7 +13,7 @@ internal static class DictionaryBuilderExtensions
{
builder.Services
.AddTransient<IDictionaryFactory, DictionaryFactory>()
.AddTransient<ILoadDictionaryItemService, LoadDictionaryItemService>();
.AddTransient<IDictionaryItemImportService, DictionaryItemImportService>();
builder.WithCollectionBuilder<MapDefinitionCollectionBuilder>().Add<DictionaryMapDefinition>();

View File

@@ -9,6 +9,7 @@ public static class FileUploadBuilderExtensions
internal static IUmbracoBuilder AddFileUpload(this IUmbracoBuilder builder)
{
builder.Services.AddTransient<IUploadFileService, UploadFileService>();
builder.Services.AddTransient<ITemporaryFileService, TemporaryFileService>();
return builder;
}

View File

@@ -1,5 +1,4 @@
using System.Xml;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Api.Management.Models;
@@ -55,37 +54,36 @@ public class DictionaryFactory : IDictionaryFactory
return updated;
}
public DictionaryImportViewModel CreateDictionaryImportViewModel(FormFileUploadResult formFileUploadResult)
{
if (formFileUploadResult.CouldLoad is false || formFileUploadResult.XmlDocument is null)
public DictionaryUploadViewModel CreateDictionaryImportViewModel(UdtFileUpload udtFileUpload) =>
new DictionaryUploadViewModel
{
throw new ArgumentNullException("The document of the FormFileUploadResult cannot be null");
}
FileName = udtFileUpload.FileName,
DictionaryItems = udtFileUpload
.Content
.Descendants("DictionaryItem")
.Select(dictionaryItem =>
{
if (Guid.TryParse(dictionaryItem.Attributes("Key").FirstOrDefault()?.Value, out Guid itemKey) == false)
{
return null;
}
var model = new DictionaryImportViewModel
{
TempFileName = formFileUploadResult.TemporaryPath, DictionaryItems = new List<DictionaryItemsImportViewModel>(),
var name = dictionaryItem.Attributes("Name").FirstOrDefault()?.Value;
if (name.IsNullOrWhiteSpace())
{
return null;
}
Guid? parentKey = Guid.TryParse(dictionaryItem.Parent?.Attributes("Key").FirstOrDefault()?.Value, out Guid key)
? key
: null;
return new DictionaryItemsImportViewModel { Name = name, Key = itemKey, ParentKey = parentKey };
})
.WhereNotNull()
.ToArray(),
};
var level = 1;
var currentParent = string.Empty;
foreach (XmlNode dictionaryItem in formFileUploadResult.XmlDocument.GetElementsByTagName("DictionaryItem"))
{
var name = dictionaryItem.Attributes?.GetNamedItem("Name")?.Value ?? string.Empty;
var parentKey = dictionaryItem.ParentNode?.Attributes?.GetNamedItem("Key")?.Value ?? string.Empty;
if (parentKey != currentParent || level == 1)
{
level += 1;
currentParent = parentKey;
}
model.DictionaryItems.Add(new DictionaryItemsImportViewModel { Level = level, Name = name });
}
return model;
}
private async Task MapTranslations(IDictionaryItem dictionaryItem, IEnumerable<DictionaryItemTranslationModel> translationModels)
{
var languagesByIsoCode = (await _languageService.GetAllAsync()).ToDictionary(l => l.IsoCode);

View File

@@ -12,5 +12,5 @@ public interface IDictionaryFactory
Task<DictionaryItemViewModel> CreateDictionaryItemViewModelAsync(IDictionaryItem dictionaryItem);
DictionaryImportViewModel CreateDictionaryImportViewModel(FormFileUploadResult formFileUploadResult);
DictionaryUploadViewModel CreateDictionaryImportViewModel(UdtFileUpload fileUpload);
}

View File

@@ -45,11 +45,12 @@ public class DictionaryMapDefinition : IMapDefinition
target.DeleteDate = null;
}
// Umbraco.Code.MapAll -Level
// Umbraco.Code.MapAll
private void Map(IDictionaryItem source, DictionaryOverviewViewModel target, MapperContext context)
{
target.Key = source.Key;
target.Name = source.ItemKey;
target.ParentKey = source.ParentId;
target.TranslatedIsoCodes = source
.Translations
.Where(translation => translation.Value.IsNullOrWhiteSpace() == false)

View File

@@ -1,14 +0,0 @@
using System.Xml;
namespace Umbraco.Cms.Api.Management.Models;
public class FormFileUploadResult
{
public bool CouldLoad { get; set; }
public XmlDocument? XmlDocument { get; set; }
public string? ErrorMessage { get; set; }
public string? TemporaryPath { get; set; }
}

View File

@@ -0,0 +1,10 @@
using System.Xml.Linq;
namespace Umbraco.Cms.Api.Management.Models;
public class UdtFileUpload
{
public required XDocument Content { get; set; }
public required string FileName { get; init; } = string.Empty;
}

View File

@@ -766,12 +766,12 @@
}
}
},
"/umbraco/management/api/v1/dictionary/export/{key}": {
"/umbraco/management/api/v1/dictionary/{key}/export": {
"get": {
"tags": [
"Dictionary"
],
"operationId": "GetDictionaryExportByKey",
"operationId": "GetDictionaryByKeyExport",
"parameters": [
{
"name": "key",
@@ -822,30 +822,25 @@
"Dictionary"
],
"operationId": "PostDictionaryImport",
"parameters": [
{
"name": "file",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "parentId",
"in": "query",
"schema": {
"type": "integer",
"format": "int32"
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DictionaryImportModel"
}
}
}
],
},
"responses": {
"200": {
"description": "Success",
"201": {
"description": "Created"
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ContentResultModel"
"$ref": "#/components/schemas/ProblemDetailsModel"
}
}
}
@@ -855,7 +850,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundResultModel"
"$ref": "#/components/schemas/ProblemDetailsModel"
}
}
}
@@ -878,7 +873,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DictionaryImportModel"
"$ref": "#/components/schemas/DictionaryUploadModel"
}
}
}
@@ -5203,25 +5198,6 @@
},
"additionalProperties": false
},
"ContentResultModel": {
"type": "object",
"properties": {
"content": {
"type": "string",
"nullable": true
},
"contentType": {
"type": "string",
"nullable": true
},
"statusCode": {
"type": "integer",
"format": "int32",
"nullable": true
}
},
"additionalProperties": false
},
"ContentTreeItemModel": {
"type": "object",
"properties": {
@@ -5568,14 +5544,12 @@
"DictionaryImportModel": {
"type": "object",
"properties": {
"dictionaryItems": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DictionaryItemsImportModel"
}
"fileName": {
"type": "string"
},
"tempFileName": {
"parentKey": {
"type": "string",
"format": "uuid",
"nullable": true
}
},
@@ -5650,13 +5624,18 @@
"DictionaryItemsImportModel": {
"type": "object",
"properties": {
"key": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string",
"nullable": true
},
"level": {
"type": "integer",
"format": "int32"
"parentKey": {
"type": "string",
"format": "uuid",
"nullable": true
}
},
"additionalProperties": false
@@ -5672,9 +5651,10 @@
"type": "string",
"format": "uuid"
},
"level": {
"type": "integer",
"format": "int32"
"parentKey": {
"type": "string",
"format": "uuid",
"nullable": true
},
"translatedIsoCodes": {
"type": "array",
@@ -5685,6 +5665,22 @@
},
"additionalProperties": false
},
"DictionaryUploadModel": {
"type": "object",
"properties": {
"dictionaryItems": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DictionaryItemsImportModel"
}
},
"fileName": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"DirectionModel": {
"enum": [
"Ascending",

View File

@@ -0,0 +1,103 @@
using System.Xml.Linq;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Api.Management.Services.OperationStatus;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Packaging;
using Umbraco.Extensions;
using File = System.IO.File;
namespace Umbraco.Cms.Api.Management.Services;
internal sealed class DictionaryItemImportService : IDictionaryItemImportService
{
private readonly IDictionaryItemService _dictionaryItemService;
private readonly PackageDataInstallation _packageDataInstallation;
private readonly ILogger<DictionaryItemImportService> _logger;
private readonly ITemporaryFileService _temporaryFileService;
public DictionaryItemImportService(
IDictionaryItemService dictionaryItemService,
PackageDataInstallation packageDataInstallation,
ILogger<DictionaryItemImportService> logger,
ITemporaryFileService temporaryFileService)
{
_dictionaryItemService = dictionaryItemService;
_packageDataInstallation = packageDataInstallation;
_logger = logger;
_temporaryFileService = temporaryFileService;
}
public async Task<Attempt<IDictionaryItem?, DictionaryImportOperationStatus>> ImportDictionaryItemFromUdtFileAsync(string fileName, Guid? parentKey, int userId = Constants.Security.SuperUserId)
{
if (".udt".InvariantEquals(Path.GetExtension(fileName)) == false)
{
return Attempt.FailWithStatus<IDictionaryItem?, DictionaryImportOperationStatus>(DictionaryImportOperationStatus.InvalidFileType, null);
}
if (parentKey.HasValue && await _dictionaryItemService.GetAsync(parentKey.Value) == null)
{
return Attempt.FailWithStatus<IDictionaryItem?, DictionaryImportOperationStatus>(DictionaryImportOperationStatus.ParentNotFound, null);
}
// load the UDT file from disk
(XDocument Document, DictionaryImportOperationStatus Status) loadResult = await LoadUdtFileAsync(fileName);
if (loadResult.Status != DictionaryImportOperationStatus.Success)
{
return Attempt.FailWithStatus<IDictionaryItem?, DictionaryImportOperationStatus>(loadResult.Status, null);
}
// import the UDT file
(IDictionaryItem? DictionaryItem, DictionaryImportOperationStatus Status) importResult = ImportUdtFile(loadResult.Document, userId, parentKey, fileName);
// clean up the UDT file (we don't care about success or failure at this point, we'll let the temporary file service handle those)
await _temporaryFileService.DeleteFileAsync(fileName);
return importResult.Status == DictionaryImportOperationStatus.Success
? Attempt.SucceedWithStatus(DictionaryImportOperationStatus.Success, importResult.DictionaryItem)
: Attempt.FailWithStatus<IDictionaryItem?, DictionaryImportOperationStatus>(importResult.Status, null);
}
private async Task<(XDocument Document, DictionaryImportOperationStatus Status)> LoadUdtFileAsync(string fileName)
{
try
{
var filePath = await _temporaryFileService.GetFilePathAsync(fileName);
await using FileStream stream = File.OpenRead(filePath);
XDocument document = await XDocument.LoadAsync(stream, LoadOptions.None, CancellationToken.None);
return document.Root != null
? (document, DictionaryImportOperationStatus.Success)
: (document, DictionaryImportOperationStatus.InvalidFileContent);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading UDT file: {FileName}", fileName);
return (new XDocument(), DictionaryImportOperationStatus.InvalidFileContent);
}
}
private (IDictionaryItem? DictionaryItem, DictionaryImportOperationStatus Status) ImportUdtFile(XDocument udtFileContent, int userId, Guid? parentKey, string fileName)
{
if (udtFileContent.Root == null)
{
return (null, DictionaryImportOperationStatus.InvalidFileContent);
}
try
{
IEnumerable<IDictionaryItem> dictionaryItems = _packageDataInstallation.ImportDictionaryItem(udtFileContent.Root, userId, parentKey);
IDictionaryItem? importedDictionaryItem = dictionaryItems.FirstOrDefault();
return importedDictionaryItem != null
? (importedDictionaryItem, DictionaryImportOperationStatus.Success)
: (null, DictionaryImportOperationStatus.InvalidFileContent);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error importing UDT file: {FileName}", fileName);
return (null, DictionaryImportOperationStatus.InvalidFileContent);
}
}
}

View File

@@ -0,0 +1,10 @@
using Umbraco.Cms.Api.Management.Services.OperationStatus;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Api.Management.Services;
public interface IDictionaryItemImportService
{
Task<Attempt<IDictionaryItem?, DictionaryImportOperationStatus>> ImportDictionaryItemFromUdtFileAsync(string fileName, Guid? parentKey, int userId = Constants.Security.SuperUserId);
}

View File

@@ -1,8 +0,0 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Api.Management.Services;
public interface ILoadDictionaryItemService
{
IDictionaryItem Load(string filePath, int? parentId);
}

View File

@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Http;
namespace Umbraco.Cms.Api.Management.Services;
public interface ITemporaryFileService
{
Task<string> GetFilePathAsync(string fileName);
Task<string> SaveFileAsync(IFormFile file);
Task<bool> DeleteFileAsync(string fileName);
}

View File

@@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Api.Management.Models;
using Umbraco.Cms.Api.Management.Services.OperationStatus;
using Umbraco.Cms.Core;
namespace Umbraco.Cms.Api.Management.Services;
public interface IUploadFileService
{
FormFileUploadResult TryLoad(IFormFile file);
Task<Attempt<UdtFileUpload, UdtFileUploadOperationStatus>> UploadUdtFileAsync(IFormFile file);
}

View File

@@ -1,55 +0,0 @@
using System.Xml;
using System.Xml.Linq;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Packaging;
namespace Umbraco.Cms.Api.Management.Services;
public class LoadDictionaryItemService : ILoadDictionaryItemService
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
// FIXME: use IDictionaryItemService instead of ILocalizationService
private readonly ILocalizationService _localizationService;
private readonly PackageDataInstallation _packageDataInstallation;
private readonly ILogger<LoadDictionaryItemService> _logger;
public LoadDictionaryItemService(
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
ILocalizationService localizationService,
PackageDataInstallation packageDataInstallation,
ILogger<LoadDictionaryItemService> logger)
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_localizationService = localizationService;
_packageDataInstallation = packageDataInstallation;
_logger = logger;
}
// FIXME: use Guid key, not integer ID for parent identification
public IDictionaryItem Load(string filePath, int? parentId)
{
var xmlDocument = new XmlDocument { XmlResolver = null };
xmlDocument.Load(filePath);
var userId = _backOfficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? 0;
var element = XElement.Parse(xmlDocument.InnerXml);
IDictionaryItem? parentDictionaryItem = _localizationService.GetDictionaryItemById(parentId ?? 0);
IEnumerable<IDictionaryItem> dictionaryItems = _packageDataInstallation.ImportDictionaryItem(element, userId, parentDictionaryItem?.Key);
// Try to clean up the temporary file.
try
{
System.IO.File.Delete(filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error cleaning up temporary udt file in {File}", filePath);
}
return dictionaryItems.First();
}
}

View File

@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Api.Management.Services.OperationStatus;
public enum DictionaryImportOperationStatus
{
Success,
ParentNotFound,
InvalidFileContent,
InvalidFileType
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Api.Management.Services.OperationStatus;
public enum UdtFileUploadOperationStatus
{
Success,
InvalidFileType,
InvalidFileContent
}

View File

@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Extensions;
namespace Umbraco.Cms.Api.Management.Services;
internal sealed class TemporaryFileService : ITemporaryFileService
{
private readonly IHostEnvironment _hostEnvironment;
private readonly ILogger<TemporaryFileService> _logger;
public TemporaryFileService(IHostEnvironment hostEnvironment, ILogger<TemporaryFileService> logger)
{
_hostEnvironment = hostEnvironment;
_logger = logger;
}
public async Task<string> GetFilePathAsync(string fileName)
{
var root = _hostEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads);
fileName = fileName.Trim(Constants.CharArrays.DoubleQuote);
var filePath = Path.Combine(root, fileName);
return await Task.FromResult(filePath);
}
public async Task<string> SaveFileAsync(IFormFile file)
{
var filePath = await GetFilePathAsync(file.FileName);
await using FileStream fileStream = File.Create(filePath);
await file.CopyToAsync(fileStream);
return filePath;
}
public async Task<bool> DeleteFileAsync(string fileName)
{
var filePath = await GetFilePathAsync(fileName);
try
{
File.Delete(filePath);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting temporary file: {FilePath}", filePath);
return await Task.FromResult(false);
}
}
}

View File

@@ -1,65 +1,42 @@
using System.Xml;
using System.Xml.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Api.Management.Models;
using Umbraco.Cms.Api.Management.Services.OperationStatus;
using Umbraco.Extensions;
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
namespace Umbraco.Cms.Api.Management.Services;
public class UploadFileService : IUploadFileService
internal sealed class UploadFileService : IUploadFileService
{
private readonly IHostEnvironment _hostEnvironment;
private readonly ILocalizedTextService _localizedTextService;
private readonly ITemporaryFileService _temporaryFileService;
public UploadFileService(IHostEnvironment hostEnvironment, ILocalizedTextService localizedTextService)
public UploadFileService(ITemporaryFileService temporaryFileService) => _temporaryFileService = temporaryFileService;
public async Task<Attempt<UdtFileUpload, UdtFileUploadOperationStatus>> UploadUdtFileAsync(IFormFile file)
{
_hostEnvironment = hostEnvironment;
_localizedTextService = localizedTextService;
}
UdtFileUpload DefaultModel() => new() { FileName = file.FileName, Content = new XDocument() };
public FormFileUploadResult TryLoad(IFormFile file)
{
var formFileUploadResult = new FormFileUploadResult();
var fileName = file.FileName.Trim(Constants.CharArrays.DoubleQuote);
var ext = fileName.Substring(fileName.LastIndexOf('.') + 1).ToLower();
var root = _hostEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads);
formFileUploadResult.TemporaryPath = Path.Combine(root, fileName);
if (!Path.GetFullPath(formFileUploadResult.TemporaryPath).StartsWith(Path.GetFullPath(root)))
if (".udt".InvariantEquals(Path.GetExtension(file.FileName)) == false)
{
formFileUploadResult.ErrorMessage = _localizedTextService.Localize("media", "invalidFileName");
formFileUploadResult.CouldLoad = false;
return formFileUploadResult;
return Attempt.FailWithStatus(UdtFileUploadOperationStatus.InvalidFileType, DefaultModel());
}
if (!ext.InvariantEquals("udt"))
var filePath = await _temporaryFileService.SaveFileAsync(file);
XDocument content;
await using (FileStream fileStream = File.OpenRead(filePath))
{
formFileUploadResult.ErrorMessage = _localizedTextService.Localize("media", "disallowedFileType");
formFileUploadResult.CouldLoad = false;
return formFileUploadResult;
content = await XDocument.LoadAsync(fileStream, LoadOptions.None, CancellationToken.None);
}
using (FileStream stream = File.Create(formFileUploadResult.TemporaryPath))
if (content.Root == null)
{
file.CopyToAsync(stream).GetAwaiter().GetResult();
return Attempt.FailWithStatus(UdtFileUploadOperationStatus.InvalidFileContent, DefaultModel());
}
formFileUploadResult.XmlDocument = new XmlDocument {XmlResolver = null};
formFileUploadResult.XmlDocument.Load(formFileUploadResult.TemporaryPath);
if (formFileUploadResult.XmlDocument.DocumentElement != null)
{
return formFileUploadResult;
}
formFileUploadResult.ErrorMessage = _localizedTextService.Localize("speechBubbles", "fileErrorNotFound");
formFileUploadResult.CouldLoad = false;
return formFileUploadResult;
// grab the file name from the file path in case the temporary file name is different from the uploaded one
var model = new UdtFileUpload { FileName = Path.GetFileName(filePath), Content = content };
return Attempt.SucceedWithStatus(UdtFileUploadOperationStatus.Success, model);
}
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Dictionary;
public class DictionaryImportModel
{
public required string FileName { get; set; }
public Guid? ParentKey { get; set; }
}

View File

@@ -1,8 +0,0 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Dictionary;
public class DictionaryImportViewModel
{
public List<DictionaryItemsImportViewModel> DictionaryItems { get; set; } = null!;
public string? TempFileName { get; set; }
}

View File

@@ -2,7 +2,9 @@
public class DictionaryItemsImportViewModel
{
public Guid Key { get; set; }
public string? Name { get; set; }
public int Level { get; set; }
public Guid? ParentKey { get; set; }
}

View File

@@ -13,9 +13,9 @@ public class DictionaryOverviewViewModel
public Guid Key { get; set; }
/// <summary>
/// Gets or sets the level.
/// Gets or sets the parent key.
/// </summary>
public int Level { get; set; }
public Guid? ParentKey { get; set; }
/// <summary>
/// Sets the translations.

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Dictionary;
public class DictionaryUploadViewModel
{
public IEnumerable<DictionaryItemsImportViewModel> DictionaryItems { get; set; } = Array.Empty<DictionaryItemsImportViewModel>();
public string? FileName { get; set; }
}