diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 1b7bcbf2d0..6e24d62eac 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -36,6 +36,7 @@ Restore Choose where to copy Choose where to move + Choose where to import to in the tree structure below Choose where to copy the selected item(s) Choose where to move the selected item(s) @@ -569,9 +570,14 @@ Modifying layout will result in loss of data for any existing content that is based on this configuration. + + To import a dictionary item, find the ".udt" file on your computer by clicking the + "Import" button (you'll be asked for confirmation on the next screen) + Dictionary item does not exist. Parent item does not exist. There are no dictionary items. + There are no dictionary items in this file. Create dictionary item @@ -1592,6 +1598,9 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Invitation has been re-sent to %0% Document Type was exported to file An error occurred while exporting the Document Type + Dictionary item(s) was exported to file + An error occurred while exporting the dictionary item(s) + The following dictionary item(s) has been imported! Domains are not configured for multilingual site, please contact an administrator, see log for more information diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index 7ebce617b5..db640d24ce 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -37,6 +37,7 @@ Restore Choose where to copy Choose where to move + Choose where to import to in the tree structure below Choose where to copy the selected item(s) Choose where to move the selected item(s) @@ -579,9 +580,14 @@ Modifying layout will result in loss of data for any existing content that is based on this configuration. + + To import a dictionary item, find the ".udt" file on your computer by clicking the + "Import" button (you'll be asked for confirmation on the next screen) + Dictionary item does not exist. Parent item does not exist. There are no dictionary items. + There are no dictionary items in this file. Create dictionary item @@ -1629,6 +1635,9 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Validation failed for language '%0%' Document Type was exported to file An error occurred while exporting the Document Type + Dictionary item(s) was exported to file + An error occurred while exporting the dictionary item(s) + The following dictionary item(s) has been imported! The release date cannot be in the past Cannot schedule the document for publishing since the required '%0%' is not published diff --git a/src/Umbraco.Core/Models/DictionaryImportModel.cs b/src/Umbraco.Core/Models/DictionaryImportModel.cs new file mode 100644 index 0000000000..2507a6a1ec --- /dev/null +++ b/src/Umbraco.Core/Models/DictionaryImportModel.cs @@ -0,0 +1,14 @@ +using System.Runtime.Serialization; + +namespace Umbraco.Cms.Core.Models +{ + [DataContract(Name = "dictionaryImportModel")] + public class DictionaryImportModel + { + [DataMember(Name = "dictionaryItems")] + public List? DictionaryItems { get; set; } + + [DataMember(Name = "tempFileName")] + public string? TempFileName { get; set; } + } +} diff --git a/src/Umbraco.Core/Models/DictionaryPreviewImportModel.cs b/src/Umbraco.Core/Models/DictionaryPreviewImportModel.cs new file mode 100644 index 0000000000..530d49b013 --- /dev/null +++ b/src/Umbraco.Core/Models/DictionaryPreviewImportModel.cs @@ -0,0 +1,14 @@ +using System.Runtime.Serialization; + +namespace Umbraco.Cms.Core.Models +{ + [DataContract(Name = "dictionaryPreviewImportModel")] + public class DictionaryPreviewImportModel + { + [DataMember(Name = "name")] + public string? Name { get; set; } + + [DataMember(Name = "level")] + public int Level { get; set; } + } +} diff --git a/src/Umbraco.Infrastructure/Packaging/PackageDataInstallation.cs b/src/Umbraco.Infrastructure/Packaging/PackageDataInstallation.cs index f0bca5f1ea..9295b7d5ac 100644 --- a/src/Umbraco.Infrastructure/Packaging/PackageDataInstallation.cs +++ b/src/Umbraco.Infrastructure/Packaging/PackageDataInstallation.cs @@ -1355,6 +1355,12 @@ namespace Umbraco.Cms.Infrastructure.Packaging return ImportDictionaryItems(dictionaryItemElementList, languages, null, userId); } + public IEnumerable ImportDictionaryItem(XElement dictionaryItemElement, int userId, Guid? parentId) + { + var languages = _localizationService.GetAllLanguages().ToList(); + return ImportDictionaryItem(dictionaryItemElement, languages, parentId, userId); + } + private IReadOnlyList ImportDictionaryItems(IEnumerable dictionaryItemElementList, List languages, Guid? parentId, int userId) { diff --git a/src/Umbraco.Web.BackOffice/Controllers/DictionaryController.cs b/src/Umbraco.Web.BackOffice/Controllers/DictionaryController.cs index 91e3385242..14c190388f 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/DictionaryController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/DictionaryController.cs @@ -1,4 +1,5 @@ using System; +using System.Xml; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -14,12 +15,16 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; +using Umbraco.Cms.Infrastructure.Packaging; using Constants = Umbraco.Cms.Core.Constants; +using System.Xml.Linq; +using Microsoft.AspNetCore.Http; namespace Umbraco.Cms.Web.BackOffice.Controllers { @@ -42,6 +47,9 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers private readonly GlobalSettings _globalSettings; private readonly ILocalizedTextService _localizedTextService; private readonly IUmbracoMapper _umbracoMapper; + private readonly IEntityXmlSerializer _serializer; + private readonly IHostingEnvironment _hostingEnvironment; + private readonly PackageDataInstallation _packageDataInstallation; public DictionaryController( ILogger logger, @@ -49,7 +57,10 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers IBackOfficeSecurityAccessor backofficeSecurityAccessor, IOptionsSnapshot globalSettings, ILocalizedTextService localizedTextService, - IUmbracoMapper umbracoMapper + IUmbracoMapper umbracoMapper, + IEntityXmlSerializer serializer, + IHostingEnvironment hostingEnvironment, + PackageDataInstallation packageDataInstallation ) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -58,6 +69,9 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers _globalSettings = globalSettings.Value ?? throw new ArgumentNullException(nameof(globalSettings)); _localizedTextService = localizedTextService ?? throw new ArgumentNullException(nameof(localizedTextService)); _umbracoMapper = umbracoMapper ?? throw new ArgumentNullException(nameof(umbracoMapper)); + _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer)); + _hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); + _packageDataInstallation = packageDataInstallation ?? throw new ArgumentNullException(nameof(packageDataInstallation)); } /// @@ -354,6 +368,126 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers } } + public IActionResult ExportDictionary(int id, bool includeChildren = false) + { + var dictionaryItem = _localizationService.GetDictionaryItemById(id); + if (dictionaryItem == null) + throw new NullReferenceException("No dictionary item found with id " + id); + + var xml = _serializer.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 File(Encoding.UTF8.GetBytes(xml.ToDataString()), MediaTypeNames.Application.Octet, fileName); + } + + public IActionResult ImportDictionary(string file, int parentId) + { + if (string.IsNullOrWhiteSpace(file)) + return NotFound(); + + var filePath = Path.Combine(_hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.Data), file); + if (!System.IO.File.Exists(filePath)) + return NotFound(); + + var xd = new XmlDocument { XmlResolver = null }; + xd.Load(filePath); + + var userId = _backofficeSecurityAccessor.BackOfficeSecurity?.GetUserId().Result ?? 0; + var element = XElement.Parse(xd.InnerXml); + + var parentDictionaryItem = _localizationService.GetDictionaryItemById(parentId); + var 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); + } + + var model = _umbracoMapper.Map(dictionaryItems.FirstOrDefault()); + return Content(model!.Path, MediaTypeNames.Text.Plain, Encoding.UTF8); + } + + public ActionResult Upload(IFormFile file) + { + + if (file == null) + return ValidationProblem( + _localizedTextService.Localize("media", "failedFileUpload"), + _localizedTextService.Localize("speechBubbles", "fileErrorNotFound")); + + var fileName = file.FileName.Trim(Constants.CharArrays.DoubleQuote); + var ext = fileName.Substring(fileName.LastIndexOf('.') + 1).ToLower(); + var root = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempFileUploads); + var tempPath = Path.Combine(root, fileName); + + if (!Path.GetFullPath(tempPath).StartsWith(Path.GetFullPath(root))) + return ValidationProblem( + _localizedTextService.Localize("media", "failedFileUpload"), + _localizedTextService.Localize("media", "invalidFileName")); + + if (!ext.InvariantEquals("udt")) + return ValidationProblem( + _localizedTextService.Localize("media", "failedFileUpload"), + _localizedTextService.Localize("media", "disallowedFileType")); + + using (var stream = System.IO.File.Create(tempPath)) + { + file.CopyToAsync(stream).GetAwaiter().GetResult(); + } + + var xd = new XmlDocument + { + XmlResolver = null + }; + xd.Load(tempPath); + + if (xd.DocumentElement == null) + return ValidationProblem( + _localizedTextService.Localize("media", "failedFileUpload"), + _localizedTextService.Localize("speechBubbles", "fileErrorNotFound")); + + DictionaryImportModel model = new DictionaryImportModel() + { + TempFileName = tempPath, + DictionaryItems = new List() + }; + + int level = 1; + string curentParrent = string.Empty; + foreach (XmlNode dictionaryItem in xd.GetElementsByTagName("DictionaryItem")) + { + var name = dictionaryItem.Attributes?.GetNamedItem("Name")?.Value ?? string.Empty; + var parentKey = dictionaryItem?.ParentNode?.Attributes?.GetNamedItem("Key")?.Value ?? string.Empty; + + if (parentKey != curentParrent || level == 1) + { + level += 1; + curentParrent = parentKey; + } + + model.DictionaryItems.Add(new DictionaryPreviewImportModel() + { + Level = level, + Name = name + }); + } + + if (!model.DictionaryItems.Any()) + return ValidationProblem( + _localizedTextService.Localize("media", "failedFileUpload"), + _localizedTextService.Localize("dictionary", "noItemsInFile")); + + return model; + } + private static Func ItemSort() => item => item.ItemKey; } } diff --git a/src/Umbraco.Web.BackOffice/Trees/DictionaryTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/DictionaryTreeController.cs index d559b87d87..0795c9b282 100644 --- a/src/Umbraco.Web.BackOffice/Trees/DictionaryTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/DictionaryTreeController.cs @@ -130,8 +130,25 @@ namespace Umbraco.Cms.Web.BackOffice.Trees if (id != Constants.System.RootString) { - menu.Items.Add(LocalizedTextService, true, opensDialog: true, useLegacyIcon: false); menu.Items.Add(LocalizedTextService, true, opensDialog: true, useLegacyIcon: false); + menu.Items.Add(new MenuItem("export", LocalizedTextService) + { + Icon = "icon-download-alt", + SeparatorBefore = true, + OpensDialog = true, + UseLegacyIcon = false + }); + menu.Items.Add(LocalizedTextService, true, opensDialog: true, useLegacyIcon: false); + } + else + { + menu.Items.Add(new MenuItem("import", LocalizedTextService) + { + Icon = "icon-page-up", + SeparatorBefore = true, + OpensDialog = true, + UseLegacyIcon = false + }); } menu.Items.Add(new RefreshNode(LocalizedTextService, true)); diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/dictionary.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/dictionary.resource.js index 38a96fbcda..fd81668d04 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/dictionary.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/dictionary.resource.js @@ -160,6 +160,77 @@ function dictionaryResource($q, $http, $location, umbRequestHelper, umbDataForma "Failed to save data for dictionary id " + dictionary.id); } + /** + * @ngdoc method + * @name umbraco.resources.dictionaryResource#export + * @methodOf umbraco.resources.dictionaryResource + * + * @description + * Export dictionary items of a given id. + * + * ##usage + *
+        * dictionaryResource.exportItem(1234){
+        *    .then(function() {
+        *       Do stuff..
+        *    });
+        * 
+ * + * @param {Int} id the ID of the dictionary item so export + * @param {Bool?} includeChildren if children should also be exported + * @returns {Promise} resourcePromise object. + * + */ + function exportItem(id, includeChildren) { + if (!id) { + throw "id cannot be null"; + } + + var url = umbRequestHelper.getApiUrl("dictionaryApiBaseUrl", "ExportDictionary", { id: id, includeChildren: includeChildren }); + + return umbRequestHelper.downloadFile(url).then(function () { + localizationService.localize("speechBubbles_dictionaryItemExportedSuccess").then(function(value) { + notificationsService.success(value); + }); + }, function (data) { + localizationService.localize("speechBubbles_dictionaryItemExportedError").then(function(value) { + notificationsService.error(value); + }); + }); + } + + /** + * @ngdoc method + * @name umbraco.resources.dictionaryResource#import + * @methodOf umbraco.resources.dictionaryResource + * + * @description + * Import a dictionary item from a file + * + * ##usage + *
+        * dictionaryResource.importItem("path to file"){
+        *    .then(function() {
+        *       Do stuff..
+        *    });
+        * 
+ * + * @param {String} file path of the file to import + * @param {Int?} parentId the int of the parent dictionary item to move incomming dictionary items to + * @returns {Promise} resourcePromise object. + * + */ + function importItem(file, parentId) { + if (!file) { + throw "file cannot be null"; + } + + return umbRequestHelper.resourcePromise( + $http.post(umbRequestHelper.getApiUrl("dictionaryApiBaseUrl", "ImportDictionary", { file: file, parentId: parentId })), + "Failed to import dictionary item " + file + ); + } + /** * @ngdoc method * @name umbraco.resources.dictionaryResource#getList @@ -194,6 +265,8 @@ function dictionaryResource($q, $http, $location, umbRequestHelper, umbDataForma getById: getById, save: save, move: move, + exportItem: exportItem, + importItem: importItem, getList : getList }; diff --git a/src/Umbraco.Web.UI.Client/src/views/dictionary/export.controller.js b/src/Umbraco.Web.UI.Client/src/views/dictionary/export.controller.js new file mode 100644 index 0000000000..df922e6d96 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/dictionary/export.controller.js @@ -0,0 +1,18 @@ +angular.module("umbraco") + .controller("Umbraco.Editors.Dictionary.ExportController", + function ($scope, dictionaryResource, navigationService) { + $scope.includeChildren = false; + + $scope.toggleHandler = function () { + $scope.includeChildren = !$scope.includeChildren + }; + + $scope.export = function () { + dictionaryResource.exportItem($scope.currentNode.id, $scope.includeChildren); + navigationService.hideMenu(); + }; + + $scope.cancel = function () { + navigationService.hideDialog(); + }; + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/dictionary/export.html b/src/Umbraco.Web.UI.Client/src/views/dictionary/export.html new file mode 100644 index 0000000000..c1f5c6b2da --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/dictionary/export.html @@ -0,0 +1,17 @@ +
+
+ + + + + +
+ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/dictionary/import.controller.js b/src/Umbraco.Web.UI.Client/src/views/dictionary/import.controller.js new file mode 100644 index 0000000000..3936217343 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/dictionary/import.controller.js @@ -0,0 +1,67 @@ +angular.module("umbraco") + .controller("Umbraco.Editors.Dictionary.ImportController", + function ($scope, dictionaryResource, notificationsService, navigationService, Upload, umbRequestHelper) { + var vm = this; + + vm.state = "upload"; + vm.model = {}; + vm.uploadStatus = ""; + + vm.cancelButtonLabel = "cancel"; + + $scope.dialogTreeApi = {}; + + function nodeSelectHandler(args) { + args.event.preventDefault(); + args.event.stopPropagation(); + + if ($scope.target) { + //un-select if there's a current one selected + $scope.target.selected = false; + } + $scope.target = args.node; + $scope.target.selected = true; + } + + $scope.handleFiles = function (files, event, invalidFiles) { + if (files && files.length > 0) { + $scope.upload(files[0]); + } + }; + + $scope.upload = function (file) { + Upload.upload({ + url: umbRequestHelper.getApiUrl("dictionaryApiBaseUrl", "Upload"), + fields: {}, + file: file + }).then(function (response) { + + vm.model = response.data; + vm.state = "confirm"; + vm.uploadStatus = "done"; + + }, function (err) { + notificationsService.error(err.data.notifications[0].header, err.data.notifications[0].message); + }); + }; + + $scope.import = function () { + var parentId = $scope.target !== undefined ? $scope.target.id : 0; + + dictionaryResource.importItem(vm.model.tempFileName, parentId).then(function (path) { + navigationService.syncTree({ tree: "dictionary", path: path, forceReload: true, activate: false }); + + vm.state = "done"; + vm.cancelButtonLabel = "general_close"; + }); + }; + + $scope.onTreeInit = function () { + $scope.dialogTreeApi.callbacks.treeNodeSelect(nodeSelectHandler); + }; + + $scope.close = function () { + navigationService.hideDialog(); + }; + + }); diff --git a/src/Umbraco.Web.UI.Client/src/views/dictionary/import.html b/src/Umbraco.Web.UI.Client/src/views/dictionary/import.html new file mode 100644 index 0000000000..64f810f4dc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/dictionary/import.html @@ -0,0 +1,81 @@ +
+ + + + +